summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/check_sast.yml6
-rw-r--r--.github/workflows/scorecards.yml2
-rw-r--r--.github/workflows/zjit-macos.yml2
-rw-r--r--.github/workflows/zjit-ubuntu.yml2
-rw-r--r--benchmark/string_inspect.yml13
-rw-r--r--doc/file/filename_globbing.md18
-rw-r--r--doc/file/filename_matching.md41
-rw-r--r--file.c34
-rw-r--r--gc.c6
-rw-r--r--lib/bundler/resolver.rb16
-rw-r--r--lib/rubygems/installer.rb19
-rw-r--r--lib/rubygems/text.rb11
-rw-r--r--spec/bundler/install/cooldown_spec.rb161
-rw-r--r--string.c27
-rw-r--r--test/rubygems/test_gem_installer.rb124
-rw-r--r--test/rubygems/test_gem_text.rb17
16 files changed, 437 insertions, 62 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/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/file.c b/file.c
index c4a531d783..fffd09c22e 100644
--- a/file.c
+++ b/file.c
@@ -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;
}
diff --git a/gc.c b/gc.c
index 2772f95638..0219fa6e78 100644
--- a/gc.c
+++ b/gc.c
@@ -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/string.c b/string.c
index 134e125431..63a5114341 100644
--- a/string.c
+++ b/string.c
@@ -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