diff options
| -rw-r--r-- | .github/workflows/check_sast.yml | 6 | ||||
| -rw-r--r-- | .github/workflows/scorecards.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/windows.yml | 1 | ||||
| -rw-r--r-- | .github/workflows/zjit-macos.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/zjit-ubuntu.yml | 2 | ||||
| -rw-r--r-- | benchmark/string_inspect.yml | 13 | ||||
| -rw-r--r-- | doc/file/filename_globbing.md | 18 | ||||
| -rw-r--r-- | doc/file/filename_matching.md | 41 | ||||
| -rw-r--r-- | ext/json/parser/parser.c | 45 | ||||
| -rw-r--r-- | ext/zlib/zlib.gemspec | 2 | ||||
| -rw-r--r-- | file.c | 34 | ||||
| -rw-r--r-- | gc.c | 6 | ||||
| -rw-r--r-- | lib/bundler/resolver.rb | 16 | ||||
| -rw-r--r-- | lib/rubygems/installer.rb | 19 | ||||
| -rw-r--r-- | lib/rubygems/text.rb | 11 | ||||
| -rw-r--r-- | spec/bundler/install/cooldown_spec.rb | 161 | ||||
| -rw-r--r-- | spec/ruby/core/io/buffer/map_spec.rb | 46 | ||||
| -rw-r--r-- | string.c | 27 | ||||
| -rw-r--r-- | test/rubygems/test_gem_installer.rb | 124 | ||||
| -rw-r--r-- | test/rubygems/test_gem_text.rb | 17 | ||||
| -rw-r--r-- | vcpkg.json | 2 |
21 files changed, 491 insertions, 104 deletions
diff --git a/.github/workflows/check_sast.yml b/.github/workflows/check_sast.yml index 0b5d6ad1b6..c8db1103ed 100644 --- a/.github/workflows/check_sast.yml +++ b/.github/workflows/check_sast.yml @@ -78,14 +78,14 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} build-mode: none config-file: .github/codeql/codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: category: '/language:${{ matrix.language }}' upload: False @@ -127,7 +127,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: sarif-results/${{ matrix.language }}.sarif continue-on-error: true diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index fdc28c2d09..6dc4a7c6ad 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -73,6 +73,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: results.sarif diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index b2c84abc6d..80a935b30f 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -96,6 +96,7 @@ jobs: - name: Install libraries with vcpkg run: | + git -C "%VCPKG_INSTALLATION_ROOT%" pull --quiet vcpkg install working-directory: src if: ${{ ! steps.restore-vcpkg.outputs.cache-hit }} diff --git a/.github/workflows/zjit-macos.yml b/.github/workflows/zjit-macos.yml index 09c7c1b6db..707e50e36b 100644 --- a/.github/workflows/zjit-macos.yml +++ b/.github/workflows/zjit-macos.yml @@ -93,7 +93,7 @@ jobs: rustup install ${{ matrix.rust_version }} --profile minimal rustup default ${{ matrix.rust_version }} - - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 + - uses: taiki-e/install-action@4bc351f7f2614e48088386e2a0ad917ca3a7e4ba # v2.81.5 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} diff --git a/.github/workflows/zjit-ubuntu.yml b/.github/workflows/zjit-ubuntu.yml index 7f5ce9322e..1c3e3f6531 100644 --- a/.github/workflows/zjit-ubuntu.yml +++ b/.github/workflows/zjit-ubuntu.yml @@ -119,7 +119,7 @@ jobs: ruby-version: '3.1' bundler: none - - uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 + - uses: taiki-e/install-action@4bc351f7f2614e48088386e2a0ad917ca3a7e4ba # v2.81.5 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} diff --git a/benchmark/string_inspect.yml b/benchmark/string_inspect.yml new file mode 100644 index 0000000000..62a884e19d --- /dev/null +++ b/benchmark/string_inspect.yml @@ -0,0 +1,13 @@ +prelude: | + ascii = "Hello, World! This is a benchmark test string." * 100 + utf8 = "こんにちは世界。これはベンチマーク用のテスト文字列です。" * 100 + mixed = ("Hello World! " + "テスト" + " is great! ") * 100 + binary = ("\xE3\x81\x82" * 100).b + escapy = "\n\t\"\\\#" * 100 + +benchmark: + inspect_ascii: ascii.inspect + inspect_utf8: utf8.inspect + inspect_mixed: mixed.inspect + inspect_binary: binary.inspect + inspect_escapy: escapy.inspect diff --git a/doc/file/filename_globbing.md b/doc/file/filename_globbing.md index 7981964c5c..ce4549bffe 100644 --- a/doc/file/filename_globbing.md +++ b/doc/file/filename_globbing.md @@ -1,22 +1,20 @@ # Filename Globbing -Filename globbing is a pattern-matching feature implemented in certain Ruby methods. - -Filename-globbing methods find filesystem entries (files and directories) -that match certain patterns; -these methods are: +Filename globbing is a pattern-matching feature implemented in certain Ruby methods: - Dir.glob. - [`Dir[]`](https://docs.ruby-lang.org/en/master/Dir.html#method-c-5B-5D). - Pathname.glob. - Pathname#glob. -These methods are quite different from filename-matching methods (not discussed here), -which match patterns against string paths, and do not access the filesystem; -those methods are: +Each `glob` method finds filesystem entries (files and directories) +that match certain patterns. + +These methods are quite different +from [filename-matching](rdoc-ref:filename_matching.md) methods, +which match patterns against string paths, and do not access the filesystem. -- File.fnmatch. -- Pathname#fnmatch. +## Patterns These are the basic elements of filename-globbing patterns; see the sections below for details: diff --git a/doc/file/filename_matching.md b/doc/file/filename_matching.md index 9f13da9012..cf5b60bac2 100644 --- a/doc/file/filename_matching.md +++ b/doc/file/filename_matching.md @@ -1,4 +1,4 @@ -## Filename Matching +# Filename Matching Filename matching is a pattern-matching feature implemented in certain Ruby methods: @@ -8,14 +8,11 @@ Filename matching is a pattern-matching feature implemented in certain Ruby meth Each `fnmatch` method matches a pattern against a string _path_; these methods operate only on strings, and do not access the file system. -These are quite different from filename globbing methods (not discussed here), -which match patterns against string paths found in the actual file system: +These methods are quite different +from [filename-globbing](rdoc-ref:filename_globbing.md) methods, +which match patterns against string paths found in the actual file system. -- Dir.glob. -- Pathname.glob. -- Pathname#glob. - -### Patterns +## Patterns These are the basic elements of filename matching patterns; see the sections below for details: @@ -36,7 +33,7 @@ There are two other patterns that are disabled by default: - Alternatives (`'{ , }'`); see [`File::FNM_EXTGLOB`](#constant-filefnmextglob) below. -#### Simple \String +### Simple \String A "simple string" is one that does not contain special filename-matching patterns; see the table above. @@ -78,7 +75,7 @@ File.fnmatch('PROGRAM~1', 'Program Files') # => false It may be enabled by flag [`File::FNM_SHORTNAME`](#constant-filefnmshortname). -#### Any Sequence of Characters (`'*'`) +### Any Sequence of Characters (`'*'`) The asterisk pattern (`'*'`) matches any sequence of characters: @@ -105,7 +102,7 @@ File.fnmatch('*.rb', 'lib/test.rb') # => true That matching may be disabled by flag [`File::FNM_PATHNAME`](#constant-filefnmpathname). -#### Single Character (`'?'`) +### Single Character (`'?'`) The question-mark pattern (`'?'`) matches any single character: @@ -125,7 +122,7 @@ File.fnmatch('foo?boo', 'foo/boo') # => true That matching may be disabled by flag [`File::FNM_PATHNAME`](#constant-filefnmpathname). -#### Single Character from a Set (`'[abc]'`, `'[^abc]'`) +### Single Character from a Set (`'[abc]'`, `'[^abc]'`) Characters enclosed in square brackets define a set of characters, any of which matches a single character: @@ -145,7 +142,7 @@ File.fnmatch('[^ruby]', 'r') # => false File.fnmatch('[^ruby]', 'u') # => false ``` -#### Single Character from a \Range (`'[a-c]'`, `'[^a-c]'`) +### Single Character from a \Range (`'[a-c]'`, `'[^a-c]'`) A range of characters enclosed in square brackets defines a set of characters, any of which matches a single character: @@ -165,7 +162,7 @@ File.fnmatch('[^a-c]', 'b') # => false File.fnmatch('[^a-c]', 'd') # => true ``` -#### Escape (`'\'`) +### Escape (`'\'`) The backslash character (`'\'`) may be used to escape any of the characters that filename matching treats as special: @@ -191,7 +188,7 @@ File.fnmatch('\\\\', '\\') # => true By default escape pattern `'\'` is enabled; it may be disabled by flag [`File::FNM_NOESCAPE`](#constant-filefnmnoescape). -### Flags +## Flags Optional argument `flags` (defaults to `0`) may be the bitwise OR of the constants `File::FNM*`. @@ -210,7 +207,7 @@ see the sections below for details: | [`File::FNM_SYSCASE`](#constant-filefnmsyscase) | Make the pattern use OS's case sensitivity. | -#### Constant File::FNM_CASEFOLD +### Constant File::FNM_CASEFOLD By default, filename matching is case-sensitive; use constant [`File::FNM_CASEFOLD`](#constant-filefnmcasefold) @@ -221,7 +218,7 @@ File.fnmatch('abc', 'ABC') # => false File.fnmatch('abc', 'ABC', File::FNM_CASEFOLD) # => true ``` -#### Constant File::FNM_DOTMATCH +### Constant File::FNM_DOTMATCH By default, filename matching does not allow pattern `'*'` to match a dotfile name (i.e, a filename beginning with a dot); @@ -232,7 +229,7 @@ to enable the match: File.fnmatch('*', '.document') # => false File.fnmatch('*', '.document', File::FNM_DOTMATCH) # => true ``` -#### Constant File::FNM_EXTGLOB +### Constant File::FNM_EXTGLOB By default, filename matching has the alternative notation disabled; use constant [`File::FNM_EXTGLOB`](#constant-filefnmextglob) @@ -261,7 +258,7 @@ File.fnmatch('{*ELLO,?????}', 'hello', File::FNM_EXTGLOB) # => true File.fnmatch('R{ub,foo,bar}y', 'Ruby') # => false ``` -#### Constant File::FNM_NOESCAPE +### Constant File::FNM_NOESCAPE By default filename matching has escaping enabled; use constant [`File::FNM_NOESCAPE`](#constant-filefnmnoescape) @@ -272,7 +269,7 @@ File.fnmatch('\*\?\*\*', '*?**') # => true File.fnmatch('\*\?\*\*', '*?**', File::FNM_NOESCAPE) # => false ``` -#### Constant File::FNM_PATHNAME +### Constant File::FNM_PATHNAME Flag [`File::FNM_PATHNAME`](#constant-filefnmpathname) affects patterns `'**'`, `'*'`, and `'?'`. @@ -316,7 +313,7 @@ File.fnmatch('foo?boo', 'foo/boo') # => true File.fnmatch('foo?boo', 'foo/boo', File::FNM_PATHNAME) # => false ``` -#### Constant File::FNM_SHORTNAME +### Constant File::FNM_SHORTNAME By default, Windows shortname matching is disabled; use constant [`File::FNM_SHORTNAME`](#constant-filefnmshortname) @@ -339,7 +336,7 @@ File.fnmatch('PROGRAM~1', 'Program Files') # => false File.fnmatch('PROGRAM~1', 'Program Files', File::FNM_SHORTNAME) # => true ``` -#### Constant File::FNM_SYSCASE +### Constant File::FNM_SYSCASE By default, filename matching uses Ruby's own case-sensitivity rules; use constant [`File::FNM_SYSCASE`](#constant-filefnmsyscase) diff --git a/ext/json/parser/parser.c b/ext/json/parser/parser.c index 0b0bfd713f..eb7dc9aa82 100644 --- a/ext/json/parser/parser.c +++ b/ext/json/parser/parser.c @@ -1093,6 +1093,27 @@ NORETURN(static) void raise_duplicate_key_error(JSON_ParserState *state, VALUE d rb_exc_raise(parse_error_new(message, line, column)); } +NOINLINE(static) void json_on_duplicate_key(JSON_ParserState *state, JSON_ParserConfig *config, size_t count, const VALUE *pairs) +{ + switch (config->on_duplicate_key) { + case JSON_IGNORE: + return; + + case JSON_DEPRECATED: + // Only emit the first few deprecations to avoid spamming. + if (state->emitted_deprecations < 5) { + emit_duplicate_key_warning(state, json_find_duplicated_key(count, pairs)); + state->emitted_deprecations++; + } + return; + + case JSON_RAISE: + raise_duplicate_key_error(state, json_find_duplicated_key(count, pairs)); + return; + } + UNREACHABLE; +} + static inline VALUE json_decode_object(JSON_ParserState *state, JSON_ParserConfig *config, size_t count) { size_t entries_count = count / 2; @@ -1101,21 +1122,7 @@ static inline VALUE json_decode_object(JSON_ParserState *state, JSON_ParserConfi rb_hash_bulk_insert(count, pairs, object); if (RB_UNLIKELY(RHASH_SIZE(object) < entries_count)) { - switch (config->on_duplicate_key) { - case JSON_IGNORE: - break; - case JSON_DEPRECATED: - // Only emit the first few deprecations to avoid spamming. - if (state->emitted_deprecations < 5) { - emit_duplicate_key_warning(state, json_find_duplicated_key(count, pairs)); - state->emitted_deprecations++; - } - - break; - case JSON_RAISE: - raise_duplicate_key_error(state, json_find_duplicated_key(count, pairs)); - break; - } + json_on_duplicate_key(state, config, count, pairs); } rvalue_stack_pop(state->value_stack, count); @@ -1414,18 +1421,18 @@ static inline long json_frame_entry_count(const json_frame *frame, const rvalue_ // after a container close is the freshly re-exposed parent. static inline void json_value_completed(json_frame *frame) { - // TODO: consider a lookup table? switch (frame->type) { case JSON_FRAME_ROOT: frame->phase = JSON_PHASE_DONE; - break; + return; case JSON_FRAME_ARRAY: frame->phase = JSON_PHASE_ARRAY_COMMA; - break; + return; case JSON_FRAME_OBJECT: frame->phase = JSON_PHASE_OBJECT_COMMA; - break; + return; } + UNREACHABLE; } // Parse an arbitrary JSON value iteratively. This is a state machine driven diff --git a/ext/zlib/zlib.gemspec b/ext/zlib/zlib.gemspec index 345dc5f225..ba7114476f 100644 --- a/ext/zlib/zlib.gemspec +++ b/ext/zlib/zlib.gemspec @@ -27,5 +27,5 @@ Gem::Specification.new do |spec| spec.executables = [] spec.require_paths = ["lib"] spec.extensions = "ext/zlib/extconf.rb" - spec.required_ruby_version = ">= 2.5.0" + spec.required_ruby_version = ">= 2.7.0" end @@ -3653,11 +3653,12 @@ has_drive_letter(const char *buf) } #ifndef _WIN32 -static char* +static VALUE getcwdofdrv(int drv) { char drive[4]; - char *drvcwd, *oldcwd; + char *oldcwd; + VALUE drvcwd; drive[0] = drv; drive[1] = ':'; @@ -3669,13 +3670,13 @@ getcwdofdrv(int drv) */ oldcwd = ruby_getcwd(); if (chdir(drive) == 0) { - drvcwd = ruby_getcwd(); + drvcwd = rb_dir_getwd_ospath(); chdir(oldcwd); xfree(oldcwd); } else { /* perhaps the drive is not exist. we return only drive letter */ - drvcwd = strdup(drive); + drvcwd = rb_enc_str_new_cstr(drive, rb_filesystem_encoding()); } return drvcwd; } @@ -4045,16 +4046,19 @@ ospath_new(const char *ptr, long len, rb_encoding *fsenc) } static char * -append_fspath(VALUE result, VALUE fname, char *dir, rb_encoding **enc, rb_encoding *fsenc) +append_fspath(VALUE result, VALUE fname, VALUE dirname, rb_encoding **enc, rb_encoding *fsenc) { - char *buf, *cwdp = dir; - VALUE dirname = Qnil; - size_t dirlen = strlen(dir), buflen = rb_str_capacity(result); + if (RB_UNLIKELY(!rb_enc_asciicompat(fsenc) || rb_enc_str_coderange(dirname) != ENC_CODERANGE_7BIT)) { + dirname = rb_str_new_shared(dirname); + rb_enc_associate(dirname, fsenc); + } + + char *buf, *cwdp; + size_t dirlen = RSTRING_LEN(dirname); + size_t buflen = rb_str_capacity(result); if (NORMALIZE_UTF8PATH || *enc != fsenc) { - dirname = ospath_new(dir, dirlen, fsenc); if (!rb_enc_compatible(fname, dirname)) { - xfree(dir); /* rb_enc_check must raise because the two encodings are not * compatible. */ rb_enc_check(fname, dirname); @@ -4063,19 +4067,15 @@ append_fspath(VALUE result, VALUE fname, char *dir, rb_encoding **enc, rb_encodi rb_encoding *direnc = fs_enc_check(fname, dirname); if (direnc != fsenc) { dirname = rb_str_conv_enc(dirname, fsenc, direnc); - RSTRING_GETMEM(dirname, cwdp, dirlen); - } - else if (NORMALIZE_UTF8PATH) { - RSTRING_GETMEM(dirname, cwdp, dirlen); } *enc = direnc; } + + RSTRING_GETMEM(dirname, cwdp, dirlen); do {buflen *= 2;} while (dirlen > buflen); rb_str_resize(result, buflen); buf = RSTRING_PTR(result); memcpy(buf, cwdp, dirlen); - xfree(dir); - if (!NIL_P(dirname)) rb_str_resize(dirname, 0); rb_enc_associate(result, *enc); return buf + dirlen; } @@ -4177,7 +4177,7 @@ rb_file_expand_path_internal(VALUE fname, VALUE dname, int abs_mode, int long_na p = pend; } else { - char *e = append_fspath(result, fname, ruby_getcwd(), &enc, fsenc); + char *e = append_fspath(result, fname, rb_dir_getwd_ospath(), &enc, fsenc); BUFINIT(); p = e; } @@ -1856,10 +1856,12 @@ os_each_obj(int argc, VALUE *argv, VALUE os) /* * call-seq: - * ObjectSpace.undefine_finalizer(obj) + * ObjectSpace.undefine_finalizer(obj) -> obj * - * Removes all finalizers for <i>obj</i>. + * Removes all finalizers registered for +obj+ with + * ObjectSpace.define_finalizer, and returns +obj+. * + * Does nothing if +obj+ has no finalizers. */ static VALUE diff --git a/lib/bundler/resolver.rb b/lib/bundler/resolver.rb index 422b726980..753e9987d5 100644 --- a/lib/bundler/resolver.rb +++ b/lib/bundler/resolver.rb @@ -456,11 +456,27 @@ module Bundler def cooldown_excluded?(spec) return false unless spec.respond_to?(:created_at) && spec.created_at return false unless spec.respond_to?(:remote) && spec.remote + return false if pinned_by_lockfile_floor?(spec) days = spec.remote.effective_cooldown return false if days.nil? || days <= 0 (cooldown_now - spec.created_at) < (days * 86_400) end + # A spec sitting exactly at a `>= locked_version` prevent-downgrade floor is + # the version the lockfile currently pins. `bundle update` and `bundle + # outdated` install that floor so resolution never moves a gem backwards. + # Filtering it out for cooldown would then make resolution impossible + # whenever the locked version is itself inside the cooldown window, which is + # exactly what happens to a lockfile written before cooldown was enabled. + # Keep it eligible; gems being explicitly updated carry an exact `=` + # requirement instead and stay subject to the cooldown filter. + def pinned_by_lockfile_floor?(spec) + return false unless defined?(@base) && @base + requirement = base_requirements[spec.name] + return false unless requirement && !requirement.exact? + requirement.requirements.any? {|op, version| op == ">=" && version == spec.version } + end + def cooldown_now @cooldown_now ||= Time.now end diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb index 15d6aac0fd..a6e1dc4730 100644 --- a/lib/rubygems/installer.rb +++ b/lib/rubygems/installer.rb @@ -299,7 +299,7 @@ class Gem::Installer File.chmod(dir_mode, gem_dir) if dir_mode - say spec.post_install_message if options[:post_install_message] && !spec.post_install_message.nil? + say clean_text(spec.post_install_message.to_s) if options[:post_install_message] && !spec.post_install_message.nil? Gem::Specification.add_spec(spec) unless @install_dir @@ -712,6 +712,18 @@ class Gem::Installer if spec.dependencies.any? {|dep| dep.name =~ /(?:\R|[<>])/ } raise Gem::InstallError, "#{spec} has an invalid dependencies" end + + if spec.executables.any? {|name| !name.is_a?(String) || name != File.basename(name) || /\A\.\.?\z|\R/.match?(name) } + raise Gem::InstallError, "#{spec} has an invalid executable" + end + + raise Gem::InstallError, "#{spec} has an invalid bindir" unless spec.bindir.is_a?(String) + + expanded_gem_dir = File.expand_path(gem_dir) + expanded_bindir = File.expand_path(File.join(gem_dir, spec.bindir)) + unless expanded_bindir == expanded_gem_dir || expanded_bindir.start_with?("#{expanded_gem_dir}/") + raise Gem::InstallError, "#{spec} has an invalid bindir" + end end ## @@ -720,6 +732,7 @@ class Gem::Installer 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 + escaped_bin_file_name = bin_file_name.gsub(/[\\']/) {|c| "\\#{c}" } <<~TEXT #{shebang bin_file_name} # @@ -743,9 +756,9 @@ class Gem::Installer end if Gem.respond_to?(:activate_and_load_bin_path) - Gem.activate_and_load_bin_path('#{spec.name}', '#{bin_file_name}', version) + Gem.activate_and_load_bin_path('#{spec.name}', '#{escaped_bin_file_name}', version) else - load Gem.activate_bin_path('#{spec.name}', '#{bin_file_name}', version) + load Gem.activate_bin_path('#{spec.name}', '#{escaped_bin_file_name}', version) end TEXT end diff --git a/lib/rubygems/text.rb b/lib/rubygems/text.rb index 88d4ce59b4..0550dc473d 100644 --- a/lib/rubygems/text.rb +++ b/lib/rubygems/text.rb @@ -8,7 +8,16 @@ 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]/, ".") + text = text.gsub(/[\000-\b\v-\f\016-\037\177]/, ".") + + # Match C1 control characters (U+0080-U+009F) as codepoints. This requires + # a valid UTF-8 string so the regexp does not split a multibyte sequence; + # strings in other encodings are left unchanged. + if text.encoding == Encoding::UTF_8 && text.valid_encoding? + text = text.gsub(/[\u0080-\u009f]/, ".") + end + + text end def truncate_text(text, description, max_length = 100_000) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index b3f57d93cc..bad7b7cf34 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -87,6 +87,26 @@ RSpec.describe "bundle install with the cooldown setting" do build_gem "ripe_gem", "2.0.0" do |s| s.date = now - (1 * 86_400) end + + # parent only resolves with the in-cooldown child 2.0.0 + build_gem "child", "1.0.0" do |s| + s.date = now - (30 * 86_400) + end + build_gem "child", "2.0.0" do |s| + s.date = now - (1 * 86_400) + end + build_gem "parent", "1.0.0" do |s| + s.add_dependency "child", ">= 2.0.0" + s.date = now - (30 * 86_400) + end + + # a cooldown-eligible version exists above the in-cooldown locked one + build_gem "upgradable", "2.0.0" do |s| + s.date = now - (1 * 86_400) + end + build_gem "upgradable", "3.0.0" do |s| + s.date = now - (30 * 86_400) + end end end @@ -268,5 +288,146 @@ RSpec.describe "bundle install with the cooldown setting" do expect(err).to match(/excluded by the cooldown setting/) expect(err).to match(/--cooldown 0/) end + + it "keeps an in-cooldown locked version on bundle update --all instead of failing" do + # Lockfile written before cooldown was enabled pins the now-in-cooldown + # latest version. A full update must not downgrade below it, and cooldown + # must not filter it out, otherwise resolution becomes impossible (#9598). + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + + it "does not fail bundle outdated when the locked version is in cooldown" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "outdated --cooldown 7", artifice: "compact_index_cooldown", raise_on_error: false + + # exit 0 means no outdated gems and, crucially, no resolution failure (exit 7) + expect(exitstatus).to eq(0) + end + + it "still applies cooldown and downgrades a gem that is updated explicitly" do + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update ripe_gem --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "keeps an in-cooldown transitive dependency on bundle update --all" do + gemfile <<-G + source "https://gem.repo3" + gem "parent" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + child (2.0.0) + parent (1.0.0) + child (>= 2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + parent + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("parent 1.0.0", "child 2.0.0") + end + + it "still upgrades to a cooldown-eligible version above the locked one" do + gemfile <<-G + source "https://gem.repo3" + gem "upgradable" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + upgradable (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + upgradable + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update --all --cooldown 7", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("upgradable 3.0.0") + end end end diff --git a/spec/ruby/core/io/buffer/map_spec.rb b/spec/ruby/core/io/buffer/map_spec.rb index 4b28539ad8..97764c2dd7 100644 --- a/spec/ruby/core/io/buffer/map_spec.rb +++ b/spec/ruby/core/io/buffer/map_spec.rb @@ -73,30 +73,34 @@ describe "IO::Buffer.map" do @buffer.should.valid? end - guard -> { Process.respond_to?(:fork) } do - it "is shareable across processes" do - file_name = tmp("shared_buffer") - @file = File.open(file_name, "w+") - @file << "I'm private" - @file.rewind - @buffer = IO::Buffer.map(@file) - - IO.popen("-") do |child_pipe| - if child_pipe - # Synchronize on child's output. - child_pipe.readlines.first.chomp.should == @buffer.to_s - @buffer.get_string.should == "I'm shared!" - - @file.read.should == "I'm shared!" - else - @buffer.set_string("I'm shared!") - puts @buffer + # IO::Buffer.map seems not shareable across processes on OpenBSD. + # See https://rubyci.s3.amazonaws.com/openbsd-current/ruby-master/log/20260129T163005Z.fail.html.gz + platform_is_not :openbsd do + guard -> { Process.respond_to?(:fork) } do + it "is shareable across processes" do + file_name = tmp("shared_buffer") + @file = File.open(file_name, "w+") + @file << "I'm private" + @file.rewind + @buffer = IO::Buffer.map(@file) + + IO.popen("-") do |child_pipe| + if child_pipe + # Synchronize on child's output. + child_pipe.readlines.first.chomp.should == @buffer.to_s + @buffer.get_string.should == "I'm shared!" + + @file.read.should == "I'm shared!" + else + @buffer.set_string("I'm shared!") + puts @buffer + end + ensure + child_pipe&.close end ensure - child_pipe&.close + File.unlink(file_name) end - ensure - File.unlink(file_name) end end @@ -7251,6 +7251,21 @@ rb_str_escape(VALUE str) return result; } +/* Lookup table for the inspect fast path. 1 marks bytes that need + * no escaping. 0 marks bytes that need escape inspection: 0x00-0x1F + * (control), 0x22 ("), 0x23 (#), 0x5C (\), 0x7F (DEL), 0x80-0xFF + * (non-ASCII). */ +static const bool inspect_no_escape[256] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 0x00-0x0F */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 0x10-0x1F */ + 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, /* 0x20-0x2F */ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, /* 0x30-0x3F */ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, /* 0x40-0x4F */ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, /* 0x50-0x5F */ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, /* 0x60-0x6F */ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, /* 0x70-0x7F */ +}; + /* * call-seq: * inspect -> string @@ -7266,10 +7281,11 @@ rb_str_inspect(VALUE str) rb_encoding *enc = rb_enc_from_index(encidx); const char *p, *pend, *prev; char buf[CHAR_ESC_LEN + 1]; - VALUE result = rb_str_buf_new(0); + VALUE result = rb_str_buf_new(RSTRING_LEN(str) + 2); /* string content + surrounding quotes */ rb_encoding *resenc = rb_default_internal_encoding(); int unicode_p = rb_enc_unicode_p(enc); int asciicompat = rb_enc_asciicompat(enc); + int cr = rb_enc_str_coderange(str); if (resenc == NULL) resenc = rb_default_external_encoding(); if (!rb_enc_asciicompat(resenc)) resenc = rb_usascii_encoding(); @@ -7282,6 +7298,15 @@ rb_str_inspect(VALUE str) unsigned int c, cc; int n; + /* Fast path: bulk-skip runs of safe ASCII bytes via a lookup table. + * Only well-formed strings (CR=7BIT for any encoding, or UTF-8 VALID) + * are eligible. */ + if (cr == ENC_CODERANGE_7BIT || + (encidx == ENCINDEX_UTF_8 && cr == ENC_CODERANGE_VALID)) { + while (p < pend && inspect_no_escape[(unsigned char)*p]) p++; + if (p >= pend) break; + } + n = rb_enc_precise_mbclen(p, pend, enc); if (!MBCLEN_CHARFOUND_P(n)) { if (p > prev) str_buf_cat(result, prev, p - prev); diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index bf7a4a8dfc..8947694f53 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -60,6 +60,21 @@ class TestGemInstaller < Gem::InstallerTestCase end end + def test_app_script_text_escapes_executable_name + installer = setup_base_installer + + malicious = "evil');system('id');#" + @spec.bindir = "bin" + write_file @spec.bin_file(malicious) do |io| + io.puts "#!/usr/bin/ruby" + end + + wrapper = installer.app_script_text malicious + + assert_includes wrapper, %q{Gem.activate_and_load_bin_path('a', 'evil\');system(\'id\');#', version)} + assert_includes wrapper, %q{load Gem.activate_bin_path('a', 'evil\');system(\'id\');#', version)} + end + def test_check_executable_overwrite installer = setup_base_installer @@ -1481,6 +1496,39 @@ class TestGemInstaller < Gem::InstallerTestCase refute_match(/I am a shiny gem!/, @ui.output) end + def test_install_sanitizes_post_install_message + # Use for_spec so the in-memory message reaches the installer verbatim; + # building a gem would escape the control characters during serialization. + @spec = setup_base_spec + @spec.post_install_message = "shiny \e]2;pwn\a gem" + + installer = Gem::Installer.for_spec @spec, post_install_message: true + installer.gem_home = @gemhome + + use_ui @ui do + installer.install + end + + assert_match(/shiny \.\]2;pwn\. gem/, @ui.output) + refute_match(/\e\]2;pwn/, @ui.output) + end + + def test_install_handles_non_string_post_install_message + # post_install_message may be a non-String (the gemspec schema allows an + # array), so sanitizing must not assume it responds to gsub. + @spec = setup_base_spec + @spec.post_install_message = %w[one two] + + installer = Gem::Installer.for_spec @spec, post_install_message: true + installer.gem_home = @gemhome + + use_ui @ui do + installer.install + end + + assert_match(/one/, @ui.output) + end + def test_install_extension_dir gemhome2 = "#{@gemhome}2" @@ -1921,6 +1969,82 @@ class TestGemInstaller < Gem::InstallerTestCase end end + def test_pre_install_checks_malicious_executables_before_eval + spec = util_spec "malicious", "1" + def spec.full_name # so the spec is buildable + "malicious-1" + end + + def spec.validate(*args); end + spec.executables = ["../../../tmp/malicious"] + + util_build_gem spec + + gem = File.join(@gemhome, "cache", spec.file_name) + + use_ui @ui do + installer = Gem::Installer.at gem + e = assert_raise Gem::InstallError do + installer.pre_install_checks + end + assert_equal "#<Gem::Specification name=malicious version=1> has an invalid executable", e.message + end + end + + def test_pre_install_checks_malicious_bindir_before_eval + spec = util_spec "malicious", "1" + def spec.full_name # so the spec is buildable + "malicious-1" + end + + def spec.validate(*args); end + spec.bindir = "../../../tmp/malicious" + + util_build_gem spec + + gem = File.join(@gemhome, "cache", spec.file_name) + + use_ui @ui do + installer = Gem::Installer.at gem + e = assert_raise Gem::InstallError do + installer.pre_install_checks + end + assert_equal "#<Gem::Specification name=malicious version=1> has an invalid bindir", e.message + end + end + + def test_pre_install_checks_non_string_executable + spec = util_spec "malicious", "1" + def spec.validate(*args); end + spec.executables = [nil] + + installer = Gem::Installer.for_spec spec + installer.gem_home = @gemhome + + use_ui @ui do + e = assert_raise Gem::InstallError do + installer.pre_install_checks + end + assert_equal "#<Gem::Specification name=malicious version=1> has an invalid executable", e.message + end + end + + def test_pre_install_checks_non_string_bindir + spec = util_spec "malicious", "1" + def spec.validate(*args); end + spec.bindir = true + + installer = Gem::Installer.for_spec spec + installer.gem_home = @gemhome + + use_ui @ui do + e = assert_raise Gem::InstallError do + installer.pre_install_checks + end + assert_equal "#<Gem::Specification name=malicious version=1> has an invalid bindir", e.message + end + end + def test_pre_install_checks_malicious_platform_before_eval gem_with_ill_formatted_platform = File.expand_path("packages/ill-formatted-platform-1.0.0.10.gem", __dir__) diff --git a/test/rubygems/test_gem_text.rb b/test/rubygems/test_gem_text.rb index 8e99610946..60739e6131 100644 --- a/test/rubygems/test_gem_text.rb +++ b/test/rubygems/test_gem_text.rb @@ -100,4 +100,21 @@ Without the wrapping, the text might not look good in the RSS feed. def test_clean_text assert_equal ".]2;nyan.", clean_text("\e]2;nyan\a") end + + def test_clean_text_strips_c1_control_characters + text = [0x41, 0x9b, 0x42].pack("U*") # "A", CSI (U+009B), "B" + assert_equal "A.B", clean_text(text) + end + + def test_clean_text_preserves_multibyte_characters + # U+0400 encodes to bytes D0 80, whose 0x80 continuation byte must not be + # mistaken for a C1 control byte. NEL (U+0085) is stripped. + text = [0x400, 0x85].pack("U*") + assert_equal [0x400, 0x2e].pack("U*"), clean_text(text) + end + + def test_clean_text_passes_through_non_unicode_encodings + text = "x\x9by".dup.force_encoding("ISO-8859-1") + assert_equal text, clean_text(text) + end end diff --git a/vcpkg.json b/vcpkg.json index 64d73c4048..c2caad14cd 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -7,5 +7,5 @@ "openssl", "zlib" ], - "builtin-baseline": "56bb2411609227288b70117ead2c47585ba07713" + "builtin-baseline": "f3e10653cc27d62a37a3763cd84b38bca07c6075" }
\ No newline at end of file |
