diff --git a/.github/workflows/check_sast.yml b/.github/workflows/check_sast.yml index 6e2b9099e87570..7192f878810607 100644 --- a/.github/workflows/check_sast.yml +++ b/.github/workflows/check_sast.yml @@ -45,7 +45,7 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1 + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 continue-on-error: true analyze: diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index d3a83852e0d1be..302c6e81cf638c 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -114,7 +114,12 @@ jobs: # https://github.com/actions/virtual-environments/issues/712#issuecomment-613004302 run: | ::- Set up VC ${{ matrix.vc }} - set | coreutils sort > old.env + + ::- Using sort.exe located in the same directory as comm.exe + ::- should probably work just fine. + for %%I in (comm.exe) do set "sort=%%~dp$PATH:I\sort.exe" + + set | "%sort%" > old.env call ..\src\win32\vssetup.cmd ^ -arch=${{ matrix.target || 'amd64' }} ^ ${{ matrix.vcvars && '-vcvars_ver=' || '' }}${{ matrix.vcvars }} @@ -124,8 +129,8 @@ jobs: set MAKEFLAGS=l set /a TEST_JOBS=(15 * %NUMBER_OF_PROCESSORS% / 10) > nul set RUBY_OPT_DIR=%GITHUB_WORKSPACE:\=/%/src/vcpkg_installed/%VCPKG_DEFAULT_TRIPLET% - set | coreutils sort > new.env - coreutils comm -13 old.env new.env >> %GITHUB_ENV% + set | "%sort%" > new.env + comm -13 old.env new.env >> %GITHUB_ENV% del *.env - name: baseruby version diff --git a/NEWS.md b/NEWS.md index 82ea31a303135a..f9a56d3075647b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -77,6 +77,7 @@ releases. * 3.1.6 to [v3.1.7][strscan-v3.1.7] * syntax_suggest 2.0.3 * timeout 0.6.1 + * 0.6.0 to [v0.6.1][timeout-v0.6.1] * zlib 3.2.3 * 3.2.2 to [v3.2.3][zlib-v3.2.3] @@ -141,6 +142,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [prism-v1.9.0]: https://github.com/ruby/prism/releases/tag/v1.9.0 [resolv-v0.7.1]: https://github.com/ruby/resolv/releases/tag/v0.7.1 [strscan-v3.1.7]: https://github.com/ruby/strscan/releases/tag/v3.1.7 +[timeout-v0.6.1]: https://github.com/ruby/timeout/releases/tag/v0.6.1 [zlib-v3.2.3]: https://github.com/ruby/zlib/releases/tag/v3.2.3 [test-unit-3.7.4]: https://github.com/test-unit/test-unit/releases/tag/3.7.4 [test-unit-3.7.5]: https://github.com/test-unit/test-unit/releases/tag/3.7.5 diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index d00a4bb916f692..6c12ca946602d7 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -476,7 +476,7 @@ def load_config(config_file) SharedHelpers.filesystem_access(config_file, :read) do |file| valid_file = file.exist? && !file.size.zero? return {} unless valid_file - serializer_class.load(file.read).inject({}) do |config, (k, v)| + (serializer_class.load(file.read) || {}).inject({}) do |config, (k, v)| k = k.dup k << "/" if /https?:/i.match?(k) && !k.end_with?("/", "__#{FALLBACK_TIMEOUT_URI_OPTION.upcase}") k.gsub!(".", "__") diff --git a/lib/rubygems/specification.rb b/lib/rubygems/specification.rb index d852332db7f92f..cf96348620262c 100644 --- a/lib/rubygems/specification.rb +++ b/lib/rubygems/specification.rb @@ -2190,10 +2190,13 @@ def raise_if_conflicts # :nodoc: end ## - # Sets rdoc_options to +value+, ensuring it is an array. + # 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 + @rdoc_options = Array(options).flat_map do |opt| + opt.is_a?(Hash) ? opt.to_a.flatten.map(&:to_s) : opt + end end ## @@ -2553,6 +2556,10 @@ def yaml_initialize(tag, vals) # :nodoc: 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 diff --git a/lib/rubygems/yaml_serializer.rb b/lib/rubygems/yaml_serializer.rb index 0a7ecaca10a50b..1721519c153fea 100644 --- a/lib/rubygems/yaml_serializer.rb +++ b/lib/rubygems/yaml_serializer.rb @@ -30,10 +30,12 @@ def initialize(items: [], tag: nil, anchor: nil) class Parser MAPPING_KEY_RE = /^((?:[^#:]|:[^ ])+):(?:[ ]+(.*))?$/ + MAX_NESTING_DEPTH = 1_000 def initialize(source) @lines = source.split("\n") @anchors = {} + @depth = 0 strip_document_prefix end @@ -69,6 +71,11 @@ def strip_document_prefix end def parse_node(base_indent) + @depth += 1 + if @depth > MAX_NESTING_DEPTH + raise Psych::SyntaxError, "exceeded maximum nesting depth (#{MAX_NESTING_DEPTH})" + end + skip_blank_and_comments return nil if @lines.empty? @@ -99,6 +106,8 @@ def parse_node(base_indent) else parse_plain_scalar(indent, anchor) end + ensure + @depth -= 1 end def parse_sequence(indent, anchor) @@ -270,7 +279,7 @@ def coerce(val) true elsif val == "false" false - elsif val == "nil" + elsif ["~", "null"].include?(val) nil elsif val == "{}" Mapping.new @@ -388,7 +397,8 @@ def strip_comment(val) class Builder VALID_OPS = %w[= != > < >= <= ~>].freeze - ARRAY_FIELDS = %w[rdoc_options files test_files executables requirements extra_rdoc_files].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_tags = Array(permitted_classes).map do |c| @@ -397,10 +407,11 @@ def initialize(permitted_classes: [], permitted_symbols: [], aliases: true) @permitted_symbols = permitted_symbols @aliases = aliases @anchor_values = {} + @alias_count = 0 end def build(node) - return {} if node.nil? + return nil if node.nil? result = build_node(node) @@ -428,6 +439,10 @@ def build_node(node) 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 @anchor_values.fetch(node.name, nil) end @@ -451,10 +466,12 @@ def build_mapping(node) build_dependency(node) when nil build_hash(node) - else + 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) @@ -480,12 +497,23 @@ def build_version(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["value"] - Gem::Platform.new(hash["value"]) - else + 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 @@ -493,7 +521,6 @@ def build_requirement(node) r = Gem::Requirement.allocate hash = pairs_to_hash(node) reqs = hash["requirements"] || hash["value"] - reqs = [] unless reqs.is_a?(Array) if reqs.is_a?(Array) && !reqs.empty? safe_reqs = [] @@ -524,8 +551,7 @@ def build_dependency(node) d = Gem::Dependency.allocate d.instance_variable_set(:@name, hash["name"]) - requirement = build_safe_requirement(hash["requirement"]) - d.instance_variable_set(:@requirement, requirement) + d.instance_variable_set(:@requirement, hash["requirement"]) type = hash["type"] type = type ? type.to_s.sub(/^:/, "").to_sym : :runtime @@ -541,7 +567,6 @@ def build_specification(hash) spec = Gem::Specification.allocate normalize_specification_version!(hash) - normalize_rdoc_options!(hash) normalize_array_fields!(hash) spec.yaml_initialize("!ruby/object:Gem::Specification", hash) @@ -557,26 +582,6 @@ def pairs_to_hash(node) result end - def build_safe_requirement(req_value) - return Gem::Requirement.default unless req_value - - converted = req_value - return Gem::Requirement.default unless converted.is_a?(Gem::Requirement) - - reqs = converted.instance_variable_get(:@requirements) - if reqs&.is_a?(Array) - valid = reqs.all? do |item| - next true if item == Gem::Requirement::DefaultRequirement - item.is_a?(Array) && item.size >= 2 && VALID_OPS.include?(item[0].to_s) - end - valid ? converted : Gem::Requirement.default - else - converted - end - rescue StandardError - Gem::Requirement.default - end - def validate_tag!(tag) unless @permitted_tags.include?(tag) if defined?(Psych::VERSION) @@ -609,26 +614,8 @@ def normalize_specification_version!(hash) hash["specification_version"] = val.to_i if val.is_a?(String) && /\A\d+\z/.match?(val) end - def normalize_rdoc_options!(hash) - opts = hash["rdoc_options"] - if opts.is_a?(Hash) - hash["rdoc_options"] = opts.values.flatten.compact.map(&:to_s) - elsif opts.is_a?(Array) - hash["rdoc_options"] = opts.flat_map do |opt| - if opt.is_a?(Hash) - opt.flat_map {|k, v| [k.to_s, v.to_s] } - elsif opt.is_a?(String) - opt - else - opt.to_s - end - end - end - end - def normalize_array_fields!(hash) ARRAY_FIELDS.each do |field| - next if field == "rdoc_options" # already handled hash[field] = normalize_array_field(hash[field]) if hash[field] end end @@ -662,7 +649,9 @@ def emit_node(obj, indent, quote: false) when Array then emit_array(obj, indent) when Time then emit_time(obj) when String then emit_string(obj, indent, quote: quote) - when Numeric, Symbol, TrueClass, FalseClass, nil + when NilClass + "\n" + when Numeric, Symbol, TrueClass, FalseClass " #{obj.inspect}\n" else " #{obj.to_s.inspect}\n" @@ -697,9 +686,9 @@ def emit_version(ver, indent) def emit_platform(plat, indent) " !ruby/object:Gem::Platform\n" \ - "#{pad(indent)}cpu: #{plat.cpu.inspect}\n" \ - "#{pad(indent)}os: #{plat.os.inspect}\n" \ - "#{pad(indent)}version: #{plat.version.inspect}\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) @@ -788,10 +777,11 @@ def dump(obj) end def load(str, permitted_classes: [], permitted_symbols: [], aliases: true) - return {} if str.nil? || str.empty? + raise TypeError, "no implicit conversion of nil into String" if str.nil? + return nil if str.empty? ast = Parser.new(str).parse - return {} if ast.nil? + return nil if ast.nil? Builder.new( permitted_classes: permitted_classes, diff --git a/test/rubygems/test_gem_commands_owner_command.rb b/test/rubygems/test_gem_commands_owner_command.rb index 80b1497c415ae9..8cd40e0eedde14 100644 --- a/test/rubygems/test_gem_commands_owner_command.rb +++ b/test/rubygems/test_gem_commands_owner_command.rb @@ -55,7 +55,11 @@ def test_show_owners end def test_show_owners_dont_load_objects - pend "testing a psych-only API" unless defined?(::Psych::DisallowedClass) + Gem.load_yaml + + # Gem::SafeYAML.load uses Psych.unsafe_load when Psych is enabled, + # which does not restrict classes. Only YAMLSerializer restricts object tags. + pend "Gem::SafeYAML.load uses Psych.unsafe_load which does not restrict classes" if Gem.use_psych? response = < "a", "b" => "a" }, Gem::SafeYAML.safe_load("a: &a a\nb: *a\n")) @@ -23,8 +45,6 @@ def test_aliases_disabled end def test_specification_version_is_integer - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:Gem::Specification name: test @@ -39,8 +59,6 @@ def test_specification_version_is_integer end def test_disallowed_class_rejected - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:SomeDisallowedClass foo: bar @@ -53,8 +71,6 @@ def test_disallowed_class_rejected end def test_disallowed_symbol_rejected - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:Gem::Dependency name: test @@ -79,8 +95,6 @@ def test_disallowed_symbol_rejected end def test_yaml_serializer_aliases_disabled - pend "Psych mode" if Gem.use_psych? - aliases_enabled = Gem::SafeYAML.aliases_enabled? Gem::SafeYAML.aliases_enabled = false refute_predicate Gem::SafeYAML, :aliases_enabled? @@ -95,8 +109,6 @@ def test_yaml_serializer_aliases_disabled end def test_real_gemspec_fileutils - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:Gem::Specification name: fileutils @@ -157,8 +169,6 @@ def test_real_gemspec_fileutils end def test_yaml_anchor_and_alias_enabled - pend "Psych mode" if Gem.use_psych? - aliases_enabled = Gem::SafeYAML.aliases_enabled? Gem::SafeYAML.aliases_enabled = true @@ -184,8 +194,6 @@ def test_yaml_anchor_and_alias_enabled end def test_real_gemspec_rubygems_bundler - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:Gem::Specification name: rubygems-bundler @@ -269,8 +277,6 @@ def test_real_gemspec_rubygems_bundler end def test_empty_requirements_array - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:Gem::Specification name: test @@ -293,15 +299,11 @@ def test_empty_requirements_array assert_equal "foo", dep.name assert_kind_of Gem::Requirement, dep.requirement - # Requirements should be empty array, not nil reqs = dep.requirement.instance_variable_get(:@requirements) - assert_kind_of Array, reqs - assert_equal [], reqs + assert_nil reqs end def test_requirements_hash_converted_to_array - pend "Psych mode" if Gem.use_psych? - # Malformed YAML where requirements is a Hash instead of Array yaml = <<~YAML !ruby/object:Gem::Requirement @@ -309,21 +311,14 @@ def test_requirements_hash_converted_to_array foo: bar YAML - req = Gem::YAMLSerializer.load(yaml, permitted_classes: ["Gem::Requirement"]) + req = yaml_load(yaml, permitted_classes: ["Gem::Requirement"]) assert_kind_of Gem::Requirement, req - # Requirements should be converted from Hash to empty Array reqs = req.instance_variable_get(:@requirements) - assert_kind_of Array, reqs - assert_equal [], reqs - - # Should not raise error when used - assert req.satisfied_by?(Gem::Version.new("1.0")) + assert_kind_of Hash, reqs end def test_rdoc_options_hash_converted_to_array - pend "Psych mode" if Gem.use_psych? - # Some gemspecs incorrectly have rdoc_options: {} instead of rdoc_options: [] yaml = <<~YAML --- !ruby/object:Gem::Specification @@ -337,49 +332,36 @@ def test_rdoc_options_hash_converted_to_array assert_kind_of Gem::Specification, spec assert_equal "test-gem", spec.name - # rdoc_options should be converted from Hash to Array - assert_kind_of Array, spec.rdoc_options assert_equal [], spec.rdoc_options end - def test_load_returns_hash_for_comment_only_yaml - pend "Psych mode" if Gem.use_psych? - + def test_load_returns_nil_for_comment_only_yaml # Bundler config files may contain only comments after deleting all keys - result = Gem::YAMLSerializer.load("---\n# BUNDLE_FOO: \"bar\"\n") - assert_kind_of Hash, result - assert_empty result + result = yaml_load("---\n# BUNDLE_FOO: \"bar\"\n") + assert_nil result end - def test_load_returns_hash_for_empty_document - pend "Psych mode" if Gem.use_psych? - - assert_equal({}, Gem::YAMLSerializer.load("---\n")) - assert_equal({}, Gem::YAMLSerializer.load("")) - assert_equal({}, Gem::YAMLSerializer.load(nil)) + def test_load_returns_nil_for_empty_document + assert_nil yaml_load("---\n") + assert_nil yaml_load("") + assert_raise(TypeError) { yaml_load(nil) } end def test_load_returns_hash_for_flow_empty_hash - pend "Psych mode" if Gem.use_psych? - - # Gem::YAMLSerializer.dump({}) produces "--- {}\n" - result = Gem::YAMLSerializer.load("--- {}\n") + # yaml_dump({}) produces "--- {}\n" + result = yaml_load("--- {}\n") assert_kind_of Hash, result assert_empty result end def test_load_parses_flow_empty_hash_as_value - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("metadata: {}\n") + result = yaml_load("metadata: {}\n") assert_kind_of Hash, result assert_kind_of Hash, result["metadata"] assert_empty result["metadata"] end def test_yaml_non_specific_tag_stripped - pend "Psych mode" if Gem.use_psych? - # Legacy RubyGems (1.x) generated YAML with ! non-specific tags like: # - ! '>=' # The ! prefix should be ignored. @@ -410,8 +392,6 @@ def test_yaml_non_specific_tag_stripped end def test_legacy_gemspec_with_anchors_and_non_specific_tags - pend "Psych mode" if Gem.use_psych? - aliases_enabled = Gem::SafeYAML.aliases_enabled? Gem::SafeYAML.aliases_enabled = true @@ -467,30 +447,24 @@ def test_legacy_gemspec_with_anchors_and_non_specific_tags end def test_non_specific_tag_on_plain_value - pend "Psych mode" if Gem.use_psych? - # ! tag on a bracketed value like rubyforge_project: ! '[none]' - result = Gem::YAMLSerializer.load("key: ! '[none]'\n") + result = yaml_load("key: ! '[none]'\n") assert_equal({ "key" => "[none]" }, result) end def test_dump_quotes_dollar_sign_values - pend "Psych mode" if Gem.use_psych? - # Values starting with $ should be quoted to preserve them as strings - yaml = Gem::YAMLSerializer.dump({ "BUNDLE_FOO" => "$BUILD_DIR", "BUNDLE_BAR" => "baz" }) + yaml = yaml_dump({ "BUNDLE_FOO" => "$BUILD_DIR", "BUNDLE_BAR" => "baz" }) assert_include yaml, 'BUNDLE_FOO: "$BUILD_DIR"' assert_include yaml, "BUNDLE_BAR: baz" # Round-trip: ensure the quoted value is parsed back correctly - result = Gem::YAMLSerializer.load(yaml) + result = yaml_load(yaml) assert_equal "$BUILD_DIR", result["BUNDLE_FOO"] assert_equal "baz", result["BUNDLE_BAR"] end def test_dump_quotes_special_characters - pend "Psych mode" if Gem.use_psych? - # Various special characters that should trigger quoting special_values = { "dollar" => "$HOME", @@ -502,32 +476,35 @@ def test_dump_quotes_special_characters "percent" => "%encoded", } - yaml = Gem::YAMLSerializer.dump(special_values) + yaml = yaml_dump(special_values) special_values.each do |key, value| assert_include yaml, "#{key}: #{value.inspect}", "Value #{value.inspect} for key #{key} should be quoted" end # Round-trip - result = Gem::YAMLSerializer.load(yaml) + result = yaml_load(yaml) special_values.each do |key, value| assert_equal value, result[key], "Round-trip failed for key #{key}" end end def test_load_ambiguous_value_with_colon - pend "Psych mode" if Gem.use_psych? - # "invalid: yaml: hah" is ambiguous YAML - our parser treats it as # {"invalid" => "yaml: hah"}, but the value looks like a nested mapping. # config_file.rb's load_file should detect this and reject it. - result = Gem::YAMLSerializer.load("invalid: yaml: hah") - assert_kind_of Hash, result - assert_equal "yaml: hah", result["invalid"] + if Gem.use_psych? + # Psych raises a syntax error for this ambiguous YAML + assert_raise(Psych::SyntaxError) do + yaml_load("invalid: yaml: hah") + end + else + result = yaml_load("invalid: yaml: hah") + assert_kind_of Hash, result + assert_equal "yaml: hah", result["invalid"] + end end def test_nested_anchor_in_array_item - pend "Psych mode" if Gem.use_psych? - # Ensure aliases are enabled for this test aliases_enabled = Gem::SafeYAML.aliases_enabled? Gem::SafeYAML.aliases_enabled = true @@ -570,8 +547,6 @@ def test_nested_anchor_in_array_item end def test_roundtrip_specification - pend "Psych mode" if Gem.use_psych? - spec = Gem::Specification.new do |s| s.name = "round-trip-test" s.version = "2.3.4" @@ -587,7 +562,7 @@ def test_roundtrip_specification s.add_dependency "rake", ">= 1.0" end - yaml = Gem::YAMLSerializer.dump(spec) + yaml = yaml_dump(spec) loaded = Gem::SafeYAML.safe_load(yaml) assert_kind_of Gem::Specification, loaded @@ -607,23 +582,86 @@ def test_roundtrip_specification assert_equal :runtime, dep.type end - def test_roundtrip_version - pend "Psych mode" if Gem.use_psych? + def test_roundtrip_specification_with_extensions + spec = Gem::Specification.new do |s| + s.name = "native-ext-test" + s.version = "1.0.0" + s.authors = ["Test"] + s.summary = "A gem with native extensions" + s.files = ["lib/native.rb", "ext/native/extconf.rb", "ext/native/native.c"] + s.extensions = ["ext/native/extconf.rb"] + s.require_paths = ["lib"] + end + + yaml = yaml_dump(spec) + loaded = Gem::SafeYAML.safe_load(yaml) + + assert_kind_of Gem::Specification, loaded + assert_equal ["ext/native/extconf.rb"], loaded.extensions + assert_equal ["ext/native/extconf.rb", "ext/native/native.c", "lib/native.rb"], loaded.files + end + + def test_roundtrip_specification_with_windows_paths + spec = Gem::Specification.new do |s| + s.name = "win-path-test" + s.version = "1.0.0" + s.authors = ["Test"] + s.summary = "A gem with Windows-style paths" + s.files = ["lib/foo.rb", "lib/foo/bar.rb"] + s.require_paths = ["lib"] + s.description = 'Installed in D:\ruby\lib\ruby\gems' + s.post_install_message = "Installed to C:\\Program Files\\Ruby\\lib\\rdoc" + end + + yaml = yaml_dump(spec) + loaded = Gem::SafeYAML.safe_load(yaml) + + assert_kind_of Gem::Specification, loaded + assert_equal 'Installed in D:\ruby\lib\ruby\gems', loaded.description + assert_equal "Installed to C:\\Program Files\\Ruby\\lib\\rdoc", loaded.post_install_message + end + + def test_roundtrip_specification_with_metadata + spec = Gem::Specification.new do |s| + s.name = "metadata-test" + s.version = "1.0.0" + s.authors = ["Test"] + s.summary = "A gem with metadata" + s.files = ["lib/foo.rb"] + s.require_paths = ["lib"] + s.metadata = { + "changelog_uri" => "https://example.com/CHANGELOG.md", + "source_code_uri" => "https://github.com/example/metadata-test", + "bug_tracker_uri" => "https://github.com/example/metadata-test/issues", + "allowed_push_host" => "https://rubygems.org", + } + end + + yaml = yaml_dump(spec) + loaded = Gem::SafeYAML.safe_load(yaml) + assert_kind_of Gem::Specification, loaded + assert_kind_of Hash, loaded.metadata + assert_equal 4, loaded.metadata.size + assert_equal "https://example.com/CHANGELOG.md", loaded.metadata["changelog_uri"] + assert_equal "https://github.com/example/metadata-test", loaded.metadata["source_code_uri"] + assert_equal "https://github.com/example/metadata-test/issues", loaded.metadata["bug_tracker_uri"] + assert_equal "https://rubygems.org", loaded.metadata["allowed_push_host"] + end + + def test_roundtrip_version ver = Gem::Version.new("1.2.3") - yaml = Gem::YAMLSerializer.dump(ver) - loaded = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + yaml = yaml_dump(ver) + loaded = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Version, loaded assert_equal ver, loaded end def test_roundtrip_platform - pend "Psych mode" if Gem.use_psych? - plat = Gem::Platform.new("x86_64-linux") - yaml = Gem::YAMLSerializer.dump(plat) - loaded = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + yaml = yaml_dump(plat) + loaded = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Platform, loaded assert_equal plat.cpu, loaded.cpu @@ -632,22 +670,18 @@ def test_roundtrip_platform end def test_roundtrip_requirement - pend "Psych mode" if Gem.use_psych? - req = Gem::Requirement.new(">= 1.0", "< 2.0") - yaml = Gem::YAMLSerializer.dump(req) - loaded = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + yaml = yaml_dump(req) + loaded = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Requirement, loaded assert_equal req.requirements.sort_by(&:to_s), loaded.requirements.sort_by(&:to_s) end def test_roundtrip_dependency - pend "Psych mode" if Gem.use_psych? - dep = Gem::Dependency.new("foo", ">= 1.0", :development) - yaml = Gem::YAMLSerializer.dump(dep) - loaded = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + yaml = yaml_dump(dep) + loaded = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Dependency, loaded assert_equal "foo", loaded.name @@ -656,28 +690,22 @@ def test_roundtrip_dependency end def test_roundtrip_nested_hash - pend "Psych mode" if Gem.use_psych? - obj = { "a" => { "b" => "c", "d" => [1, 2, 3] } } - yaml = Gem::YAMLSerializer.dump(obj) - loaded = Gem::YAMLSerializer.load(yaml) + yaml = yaml_dump(obj) + loaded = yaml_load(yaml) assert_equal obj, loaded end def test_roundtrip_block_scalar - pend "Psych mode" if Gem.use_psych? - obj = { "text" => "line1\nline2\n" } - yaml = Gem::YAMLSerializer.dump(obj) - loaded = Gem::YAMLSerializer.load(yaml) + yaml = yaml_dump(obj) + loaded = yaml_load(yaml) assert_equal "line1\nline2\n", loaded["text"] end def test_roundtrip_special_characters - pend "Psych mode" if Gem.use_psych? - obj = { "dollar" => "$HOME", "exclamation" => "!important", @@ -689,8 +717,8 @@ def test_roundtrip_special_characters "braces" => "{key}", "comma" => "a,b,c", } - yaml = Gem::YAMLSerializer.dump(obj) - loaded = Gem::YAMLSerializer.load(yaml) + yaml = yaml_dump(obj) + loaded = yaml_load(yaml) obj.each do |key, value| assert_equal value, loaded[key], "Round-trip failed for key #{key}" @@ -698,11 +726,9 @@ def test_roundtrip_special_characters end def test_roundtrip_boolean_nil_integer - pend "Psych mode" if Gem.use_psych? - obj = { "flag" => true, "count" => 42, "empty" => nil, "off" => false } - yaml = Gem::YAMLSerializer.dump(obj) - loaded = Gem::YAMLSerializer.load(yaml) + yaml = yaml_dump(obj) + loaded = yaml_load(yaml) assert_equal true, loaded["flag"] assert_equal 42, loaded["count"] @@ -711,12 +737,10 @@ def test_roundtrip_boolean_nil_integer end def test_roundtrip_time - pend "Psych mode" if Gem.use_psych? - time = Time.utc(2024, 6, 15, 12, 30, 45) obj = { "created" => time } - yaml = Gem::YAMLSerializer.dump(obj) - loaded = Gem::YAMLSerializer.load(yaml) + yaml = yaml_dump(obj) + loaded = yaml_load(yaml) assert_kind_of Time, loaded["created"] assert_equal time.year, loaded["created"].year @@ -725,111 +749,98 @@ def test_roundtrip_time end def test_roundtrip_empty_collections - pend "Psych mode" if Gem.use_psych? - obj = { "arr" => [], "hash" => {} } - yaml = Gem::YAMLSerializer.dump(obj) - loaded = Gem::YAMLSerializer.load(yaml) + yaml = yaml_dump(obj) + loaded = yaml_load(yaml) assert_equal [], loaded["arr"] assert_equal({}, loaded["hash"]) end def test_load_double_quoted_escape_sequences - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("newline: \"hello\\nworld\"") + result = yaml_load("newline: \"hello\\nworld\"") assert_equal "hello\nworld", result["newline"] - result = Gem::YAMLSerializer.load("tab: \"col1\\tcol2\"") + result = yaml_load("tab: \"col1\\tcol2\"") assert_equal "col1\tcol2", result["tab"] - result = Gem::YAMLSerializer.load("cr: \"line\\rend\"") + result = yaml_load("cr: \"line\\rend\"") assert_equal "line\rend", result["cr"] - result = Gem::YAMLSerializer.load("quote: \"say\\\"hi\\\"\"") + result = yaml_load("quote: \"say\\\"hi\\\"\"") assert_equal "say\"hi\"", result["quote"] end def test_load_double_quoted_backslash_before_escape_chars - pend "Psych mode" if Gem.use_psych? - # \\r in YAML should become literal backslash + r, not carriage return - result = Gem::YAMLSerializer.load('path: "D:\\\\ruby-mswin\\\\lib"') + result = yaml_load('path: "D:\\\\ruby-mswin\\\\lib"') assert_equal "D:\\ruby-mswin\\lib", result["path"] # \\n should become literal backslash + n, not newline - result = Gem::YAMLSerializer.load('path: "C:\\\\new_folder"') + result = yaml_load('path: "C:\\\\new_folder"') assert_equal "C:\\new_folder", result["path"] # \\t should become literal backslash + t, not tab - result = Gem::YAMLSerializer.load('path: "C:\\\\tmp\\\\test"') + result = yaml_load('path: "C:\\\\tmp\\\\test"') assert_equal "C:\\tmp\\test", result["path"] # \\\\ should become two literal backslashes - result = Gem::YAMLSerializer.load('val: "a\\\\\\\\b"') + result = yaml_load('val: "a\\\\\\\\b"') assert_equal "a\\\\b", result["val"] end def test_load_single_quoted_escape - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: 'it''s'") + result = yaml_load("key: 'it''s'") assert_equal "it's", result["key"] - result = Gem::YAMLSerializer.load("key: 'no escape \\n here'") + result = yaml_load("key: 'no escape \\n here'") assert_equal "no escape \\n here", result["key"] end def test_load_quoted_numeric_stays_string - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: \"42\"") + result = yaml_load("key: \"42\"") assert_equal "42", result["key"] assert_kind_of String, result["key"] - result = Gem::YAMLSerializer.load("key: '99'") + result = yaml_load("key: '99'") assert_equal "99", result["key"] assert_kind_of String, result["key"] end def test_load_empty_string_value - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: \"\"") + result = yaml_load("key: \"\"") assert_equal "", result["key"] end def test_load_unquoted_integer - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: 42") + result = yaml_load("key: 42") assert_equal 42, result["key"] assert_kind_of Integer, result["key"] - result = Gem::YAMLSerializer.load("key: -7") + result = yaml_load("key: -7") assert_equal(-7, result["key"]) end def test_load_boolean_values - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("a: true\nb: false") + result = yaml_load("a: true\nb: false") assert_equal true, result["a"] assert_equal false, result["b"] end def test_load_nil_value - pend "Psych mode" if Gem.use_psych? + # YAML 1.2: "nil" is not a null value, only ~ and null are + result = yaml_load("key: nil") + assert_equal "nil", result["key"] + + result = yaml_load("key: ~") + assert_nil result["key"] - result = Gem::YAMLSerializer.load("key: nil") + result = yaml_load("key: null") assert_nil result["key"] end def test_load_time_value - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("date: 2024-06-15 12:30:45.000000000 Z") + result = yaml_load("date: 2024-06-15 12:30:45.000000000 Z") assert_kind_of Time, result["date"] assert_equal 2024, result["date"].year assert_equal 6, result["date"].month @@ -837,88 +848,66 @@ def test_load_time_value end def test_load_block_scalar_keep_trailing_newline - pend "Psych mode" if Gem.use_psych? - yaml = "text: |\n line1\n line2\n" - result = Gem::YAMLSerializer.load(yaml) + result = yaml_load(yaml) assert_equal "line1\nline2\n", result["text"] end def test_load_block_scalar_strip_trailing_newline - pend "Psych mode" if Gem.use_psych? - yaml = "text: |-\n no trailing newline\n" - result = Gem::YAMLSerializer.load(yaml) + result = yaml_load(yaml) assert_equal "no trailing newline", result["text"] refute result["text"].end_with?("\n") end def test_load_flow_array - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("items: [a, b, c]") + result = yaml_load("items: [a, b, c]") assert_equal ["a", "b", "c"], result["items"] end def test_load_flow_empty_array - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("items: []") + result = yaml_load("items: []") assert_equal [], result["items"] end def test_load_mapping_key_with_no_value - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key:") + result = yaml_load("key:") assert_kind_of Hash, result assert_nil result["key"] end def test_load_sequence_item_as_mapping - pend "Psych mode" if Gem.use_psych? - yaml = "items:\n- name: foo\n ver: 1\n- name: bar\n ver: 2" - result = Gem::YAMLSerializer.load(yaml) + result = yaml_load(yaml) assert_equal [{ "name" => "foo", "ver" => 1 }, { "name" => "bar", "ver" => 2 }], result["items"] end def test_load_nested_sequence - pend "Psych mode" if Gem.use_psych? - yaml = "matrix:\n- - a\n - b\n- - c\n - d" - result = Gem::YAMLSerializer.load(yaml) + result = yaml_load(yaml) assert_equal [["a", "b"], ["c", "d"]], result["matrix"] end def test_load_comment_stripped_from_value - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: value # this is a comment") + result = yaml_load("key: value # this is a comment") assert_equal "value", result["key"] end def test_load_comment_in_quoted_string_preserved - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: \"value # not a comment\"") + result = yaml_load("key: \"value # not a comment\"") assert_equal "value # not a comment", result["key"] - result = Gem::YAMLSerializer.load("key: 'value # not a comment'") + result = yaml_load("key: 'value # not a comment'") assert_equal "value # not a comment", result["key"] end def test_load_crlf_line_endings - pend "Psych mode" if Gem.use_psych? - - result = Gem::YAMLSerializer.load("key: value\r\nother: data\r\n") + result = yaml_load("key: value\r\nother: data\r\n") assert_equal "value", result["key"] assert_equal "data", result["other"] end def test_load_version_requirement_old_tag - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML !ruby/object:Gem::Version::Requirement requirements: @@ -927,50 +916,48 @@ def test_load_version_requirement_old_tag version: "1.0" YAML - req = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + req = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Requirement, req assert_equal [[">=", Gem::Version.new("1.0")]], req.requirements end def test_load_platform_from_value_field - pend "Psych mode" if Gem.use_psych? - yaml = "!ruby/object:Gem::Platform\nvalue: x86-linux\n" - plat = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + plat = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Platform, plat - assert_equal "x86", plat.cpu - assert_equal "linux", plat.os + assert_nil plat.cpu end def test_load_platform_from_cpu_os_version_fields - pend "Psych mode" if Gem.use_psych? - yaml = "!ruby/object:Gem::Platform\ncpu: x86_64\nos: darwin\nversion: nil\n" - plat = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + plat = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Platform, plat assert_equal "x86_64", plat.cpu assert_equal "darwin", plat.os end - def test_load_dependency_missing_requirement_uses_default - pend "Psych mode" if Gem.use_psych? + def test_load_platform_malicious_sequence + yaml = "!ruby/object:Gem::Platform\n- \"x86-mswin32\\n system('id')#\"\n" + result = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + refute_kind_of Gem::Platform, result + assert_kind_of Array, result + end + def test_load_dependency_missing_requirement_uses_default yaml = <<~YAML !ruby/object:Gem::Dependency name: foo type: :runtime YAML - dep = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + dep = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_kind_of Gem::Dependency, dep assert_equal "foo", dep.name assert_equal :runtime, dep.type - assert_kind_of Gem::Requirement, dep.requirement + assert_nil dep.instance_variable_get(:@requirement) end def test_load_dependency_missing_type_defaults_to_runtime - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML !ruby/object:Gem::Dependency name: bar @@ -981,13 +968,11 @@ def test_load_dependency_missing_type_defaults_to_runtime version: '0' YAML - dep = Gem::YAMLSerializer.load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) + dep = yaml_load(yaml, permitted_classes: Gem::SafeYAML::PERMITTED_CLASSES) assert_equal :runtime, dep.type end def test_specification_version_non_numeric_string_not_converted - pend "Psych mode" if Gem.use_psych? - yaml = <<~YAML --- !ruby/object:Gem::Specification name: test @@ -1002,65 +987,49 @@ def test_specification_version_non_numeric_string_not_converted assert_equal "abc", spec.specification_version end - def test_unknown_permitted_tag_returns_hash_with_tag - pend "Psych mode" if Gem.use_psych? - + def test_unknown_permitted_tag_raises_argument_error yaml = "!ruby/object:MyCustomClass\nfoo: bar\n" - result = Gem::YAMLSerializer.load(yaml, permitted_classes: ["MyCustomClass"]) - assert_kind_of Hash, result - assert_equal "bar", result["foo"] - assert_equal "!ruby/object:MyCustomClass", result[:tag] + assert_raise(ArgumentError) do + yaml_load(yaml, permitted_classes: ["MyCustomClass"]) + end end def test_dump_block_scalar_with_trailing_newline - pend "Psych mode" if Gem.use_psych? - - yaml = Gem::YAMLSerializer.dump({ "text" => "line1\nline2\n" }) + yaml = yaml_dump({ "text" => "line1\nline2\n" }) assert_include yaml, " |\n" refute_includes yaml, " |-\n" end def test_dump_block_scalar_without_trailing_newline - pend "Psych mode" if Gem.use_psych? - - yaml = Gem::YAMLSerializer.dump({ "text" => "line1\nline2" }) + yaml = yaml_dump({ "text" => "line1\nline2" }) assert_include yaml, " |-\n" end def test_dump_nil_value - pend "Psych mode" if Gem.use_psych? - - yaml = Gem::YAMLSerializer.dump({ "key" => nil }) - assert_include yaml, "key: nil\n" + yaml = yaml_dump({ "key" => nil }) - loaded = Gem::YAMLSerializer.load(yaml) + loaded = yaml_load(yaml) assert_nil loaded["key"] end def test_dump_symbol_keys_quoted - pend "Psych mode" if Gem.use_psych? - - yaml = Gem::YAMLSerializer.dump({ foo: "bar" }) + yaml = yaml_dump({ foo: "bar" }) # Symbol keys should use inspect format assert_include yaml, ":foo:" # Symbol values in hash with symbol keys should be quoted - yaml = Gem::YAMLSerializer.dump({ type: ":runtime" }) + yaml = yaml_dump({ type: ":runtime" }) assert_include yaml, "\":runtime\"" end def test_regression_flow_empty_hash_as_root - pend "Psych mode" if Gem.use_psych? - # Previously returned Mapping struct instead of Hash - result = Gem::YAMLSerializer.load("--- {}") + result = yaml_load("--- {}") assert_kind_of Hash, result assert_empty result end def test_regression_alias_check_in_builder_not_parser - pend "Psych mode" if Gem.use_psych? - # Previously aliases were resolved in Parser, bypassing Builder's policy check. # The Builder must enforce aliases: false. aliases_enabled = Gem::SafeYAML.aliases_enabled? @@ -1068,20 +1037,18 @@ def test_regression_alias_check_in_builder_not_parser # Alias in mapping value assert_raise(Psych::AliasesNotEnabled) do - Gem::YAMLSerializer.load("a: &x val\nb: *x", aliases: false) + yaml_load("a: &x val\nb: *x", aliases: false) end # Alias in sequence item assert_raise(Psych::AliasesNotEnabled) do - Gem::YAMLSerializer.load("items:\n- &x val\n- *x", aliases: false) + yaml_load("items:\n- &x val\n- *x", aliases: false) end ensure Gem::SafeYAML.aliases_enabled = aliases_enabled end def test_regression_anchored_mapping_stored_for_alias_resolution - pend "Psych mode" if Gem.use_psych? - # Previously build_mapping didn't call store_anchor, so anchored # Gem types (Requirement, etc.) couldn't be resolved via aliases. aliases_enabled = Gem::SafeYAML.aliases_enabled? @@ -1105,8 +1072,6 @@ def test_regression_anchored_mapping_stored_for_alias_resolution end def test_regression_register_anchor_sets_node_anchor - pend "Psych mode" if Gem.use_psych? - # Previously register_anchor only stored node in @anchors hash but # didn't set node.anchor, so Builder couldn't track anchored values. aliases_enabled = Gem::SafeYAML.aliases_enabled? @@ -1130,20 +1095,16 @@ def test_regression_register_anchor_sets_node_anchor end def test_regression_coerce_empty_hash_not_wrapped_in_scalar - pend "Psych mode" if Gem.use_psych? - # Previously coerce("{}") returned Mapping but parse_plain_scalar # wrapped it in Scalar.new(value: Mapping), causing type mismatch. - result = Gem::YAMLSerializer.load("--- {}") + result = yaml_load("--- {}") assert_kind_of Hash, result - result = Gem::YAMLSerializer.load("key: {}") + result = yaml_load("key: {}") assert_kind_of Hash, result["key"] end def test_regression_rdoc_options_normalized_to_array - pend "Psych mode" if Gem.use_psych? - # rdoc_options as Hash (malformed gemspec) yaml = <<~YAML --- !ruby/object:Gem::Specification @@ -1156,15 +1117,10 @@ def test_regression_rdoc_options_normalized_to_array YAML spec = Gem::SafeYAML.safe_load(yaml) - assert_kind_of Array, spec.rdoc_options - # Hash rdoc_options: normalize_rdoc_options! extracts values - assert_include spec.rdoc_options, "MyGem" - assert_include spec.rdoc_options, "README" + assert_equal ["--title", "MyGem", "--main", "README"], spec.rdoc_options end def test_regression_requirements_field_normalized_to_array - pend "Psych mode" if Gem.use_psych? - # The "requirements" field in a Specification (not Requirement) # should be normalized from Hash to Array if malformed yaml = <<~YAML @@ -1177,6 +1133,6 @@ def test_regression_requirements_field_normalized_to_array YAML spec = Gem::SafeYAML.safe_load(yaml) - assert_kind_of Array, spec.requirements + assert_equal [["foo", "bar"]], spec.requirements end end