Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions benchmark/string_scrub.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
prelude: |

STRING_SIZE = 1024
def duplicate_to_length(str, target_length)
return "" if target_length <= 0
return str[0, target_length] if str.length >= target_length

(str * ((target_length / str.length) + 1))[0, target_length]
end
base = "Hello \u{1f600} world! \u{00e9}\u{00f1}"
padding = duplicate_to_length(base, STRING_SIZE)

valid_utf8 = (padding.b + "OK".b).force_encoding("UTF-8")
valid_utf8.valid_encoding?
unknown_but_valid_utf8 = valid_utf8.dup.b.force_encoding("UTF-8")
invalid_utf8 = (padding.b + "\x80\xFF".b).force_encoding("UTF-8")
invalid_utf8.valid_encoding?
unknown_but_invalid_utf8 = (padding.b + "\x80\xFF".b).force_encoding("UTF-8")

worst_case_utf8 = duplicate_to_length("\u{1f600}\u{00e9}\u{00f1}", STRING_SIZE).b.force_encoding("UTF-8")

unknown_but_valid_utf8_worst_case = worst_case_utf8.dup.b.force_encoding("UTF-8")
unknown_but_invalid_utf8_worst_case = (worst_case_utf8.b + "\x80\xFF".b).force_encoding("UTF-8")

benchmark:
scrub_known_valid: |
string = valid_utf8.dup
string.scrub!

scrub_known_invalid: |
string = invalid_utf8.dup
string.scrub!

scrub_unknown_but_valid_coderange: |
string = unknown_but_valid_utf8.dup
string.scrub!

scrub_unknown_and_invalid_coderange: |
string = unknown_but_invalid_utf8.dup
string.scrub!

scrub_unknown_but_valid_coderange_worst_case: |
string = unknown_but_valid_utf8_worst_case.dup
string.scrub!

scrub_unknown_and_invalid_coderange_worst_case: |
string = unknown_but_invalid_utf8_worst_case.dup
string.scrub!
5 changes: 5 additions & 0 deletions lib/bundler/installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ def run(options)
Bundler.create_bundle_path

ProcessLock.lock do
# Invalidate any stale gem specification cache from before we acquired the lock.
# Another process may have installed gems while we were waiting.
Gem::Specification.reset
@definition.sources.clear_cache

@definition.ensure_equivalent_gemfile_and_lockfile(options[:deployment])

if @definition.dependencies.empty?
Expand Down
7 changes: 7 additions & 0 deletions lib/bundler/source/rubygems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,13 @@ def dependency_api_available?
@allow_remote && api_fetchers.any?
end

def clear_cache
@specs = nil
@installed_specs = nil
@default_specs = nil
@cached_specs = nil
end

protected

def remote_names
Expand Down
4 changes: 4 additions & 0 deletions lib/bundler/source_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ def remote!
all_sources.each(&:remote!)
end

def clear_cache
rubygems_sources.each(&:clear_cache)
end

private

def map_sources(replacement_sources)
Expand Down
5 changes: 5 additions & 0 deletions lib/rubygems/package.rb
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,11 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc:
directories << mkdir
end

real_mkdir = File.realpath(mkdir)
unless real_mkdir == destination_dir || normalize_path(real_mkdir).start_with?(normalize_path(destination_dir + "/"))
raise Gem::Package::PathError.new(real_mkdir, destination_dir)
end

if entry.file?
File.open(destination, "wb") do |out|
copy_stream(tar.io, out, entry.size)
Expand Down
19 changes: 19 additions & 0 deletions lib/rubygems/yaml_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,25 @@ def parse_node(base_indent)

if stripped.start_with?("- ") || stripped == "-"
parse_sequence(indent, anchor)
elsif stripped.start_with?("\"") && stripped.end_with?("\"")
# We don't need to care about the following case here:
# 1. "value with comment" # ...
# 2. "key": "value"
#
# 1. must not happen because YAMLSerializer doesn't emit any
# comment. YAMLSerializer parses only YAML that is generated
# by YAMLSerializer.
#
# 2. must not happen because #parse_node isn't used non
# top-level mapping. Non top-level mapping always uses
# #parse_mapping. Top-level mapping never use the '"key":
# "value"' form because all top-level keys
# ("!ruby/object:Gem::Specification"'s keys) are known and
# #emit_specification doesn't quote anything.
parse_plain_scalar(indent, anchor)
elsif stripped.start_with?("'") && stripped.end_with?("'")
# See also the above note for double quotation.
parse_plain_scalar(indent, anchor)
elsif stripped =~ MAPPING_KEY_RE && !stripped.start_with?("!ruby/object:")
parse_mapping(indent, anchor)
elsif stripped.start_with?("!ruby/object:")
Expand Down
39 changes: 39 additions & 0 deletions spec/bundler/bundler/source/rubygems_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,45 @@
end
end

describe "#clear_cache" do
it "invalidates memoized indexes so subsequent reads rebuild them" do
source = described_class.new

first_specs = source.specs
first_installed = source.send(:installed_specs)
first_default = source.send(:default_specs)
first_cached = source.send(:cached_specs)

expect(source.specs).to equal(first_specs)
expect(source.send(:installed_specs)).to equal(first_installed)
expect(source.send(:default_specs)).to equal(first_default)
expect(source.send(:cached_specs)).to equal(first_cached)

source.clear_cache

expect(source.specs).not_to equal(first_specs)
expect(source.send(:installed_specs)).not_to equal(first_installed)
expect(source.send(:default_specs)).not_to equal(first_default)
expect(source.send(:cached_specs)).not_to equal(first_cached)
end

it "reflects newly-discovered installed gems after clear_cache" do
source = described_class.new
foo = Gem::Specification.new("foo", "1.0.0")
bar = Gem::Specification.new("bar", "1.0.0")

allow(Bundler.rubygems).to receive(:installed_specs).and_return([foo])
expect(source.send(:installed_specs).search("bar")).to be_empty

allow(Bundler.rubygems).to receive(:installed_specs).and_return([foo, bar])
expect(source.send(:installed_specs).search("bar")).to be_empty

source.clear_cache

expect(source.send(:installed_specs).search("bar")).not_to be_empty
end
end

describe "log debug information" do
it "log the time spent downloading and installing a gem" do
build_repo2 do
Expand Down
10 changes: 10 additions & 0 deletions spec/bundler/bundler/source_list_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,16 @@
end
end

describe "#clear_cache" do
let(:rubygems_source) { source_list.add_rubygems_source("remotes" => ["https://rubygems.org"]) }

it "calls #clear_cache on all rubygems sources" do
expect(rubygems_source).to receive(:clear_cache)
expect(source_list.global_rubygems_source).to receive(:clear_cache)
source_list.clear_cache
end
end

describe "implicit_global_source?" do
context "when a global rubygem source provided" do
it "returns a falsy value" do
Expand Down
56 changes: 56 additions & 0 deletions spec/bundler/install/process_lock_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,61 @@
expect(processed).to eq true
end
end

it "refreshes gem specification cache after waiting for lock" do
build_repo2 do
build_gem "myrack", "1.0.0"
end

gemfile <<-G
source "https://gem.repo2"
gem "myrack"
G

# First, install the gem so it's available
bundle "install"
expect(out).to include("Installing myrack")

# Queue for thread-safe communication
lock_acquired = Queue.new
can_release_lock = Queue.new
install_output = Queue.new

# Thread holds lock (simulating another bundle process that just finished installing)
thread = Thread.new do
Bundler::ProcessLock.lock(default_bundle_path) do
# Signal that we have the lock
lock_acquired << true
# Wait until main thread signals we can release
can_release_lock.pop
end
end

# Wait for thread to acquire lock
lock_acquired.pop

# Start another install in a thread - it will wait for the lock
install_thread = Thread.new do
bundle "install", verbose: true
install_output << out
end

# Give subprocess time to start and begin waiting for lock
sleep 0.5

# Signal thread to release the lock
can_release_lock << true

# Wait for both threads to complete
thread.join
install_thread.join

second_install_out = install_output.pop

expect(the_bundle).to include_gems "myrack 1.0.0"
# The second install should have refreshed its cache after acquiring
# the lock and seen that myrack was already installed
expect(second_install_out).to include("Using myrack")
end
end
end
12 changes: 12 additions & 0 deletions string.c
Original file line number Diff line number Diff line change
Expand Up @@ -11850,6 +11850,18 @@ enc_str_scrub(rb_encoding *enc, VALUE str, VALUE repl, int cr)
else if (MBCLEN_CHARFOUND_P(ret)) {
cr = ENC_CODERANGE_VALID;
p += MBCLEN_CHARFOUND_LEN(ret);
/*
* After a valid multibyte character, skip the following ASCII run.
* If the next byte is already non-ASCII, search_nonascii would only
* rediscover p after its word-at-a-time setup.
*/
if (p < e && ISASCII(*p)) {
p = search_nonascii(p, e);
if (!p) {
p = e;
break;
}
}
}
else if (MBCLEN_INVALID_P(ret)) {
/*
Expand Down
26 changes: 26 additions & 0 deletions test/rubygems/test_gem_package.rb
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,32 @@ def test_extract_tar_gz_symlink_directory
File.read(extracted)
end

def test_extract_tar_gz_rejects_preexisting_symlink_escape
omit "Symlinks not supported or not enabled" unless symlink_supported?

package = Gem::Package.new @gem

tgz_io = util_tar_gz do |tar|
tar.add_file "lib/owned.txt", 0o644 do |io|
io.write "poc-content"
end
end

escape_dir = File.join(@tempdir, "escape")
FileUtils.mkdir_p escape_dir

FileUtils.rm_rf File.join(@destination, "lib")
File.symlink escape_dir, File.join(@destination, "lib")

escaped = File.join(escape_dir, "owned.txt")

assert_raise Gem::Package::PathError do
package.extract_tar_gz tgz_io, @destination
end

refute File.exist?(escaped), "must not write outside extraction root via symlink"
end

def test_extract_symlink_into_symlink_dir
omit "Symlinks not supported or not enabled" unless symlink_supported?
package = Gem::Package.new @gem
Expand Down
14 changes: 14 additions & 0 deletions test/rubygems/test_gem_safe_yaml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ def test_requirements_hash_converted_to_array
assert_kind_of Hash, reqs
end

def test_requirement_quote
yaml = <<~YAML
requirements:
- "system: arrow-glib>=25.0.0: amazon_linux: arrow-glib-devel"
- 'system: arrow-glib>=25.0.0: fedora: libarrow-glib-devel'
YAML

expected = [
"system: arrow-glib>=25.0.0: amazon_linux: arrow-glib-devel",
"system: arrow-glib>=25.0.0: fedora: libarrow-glib-devel",
]
assert_equal expected, yaml_load(yaml)["requirements"]
end

def test_rdoc_options_hash_converted_to_array
# Some gemspecs incorrectly have rdoc_options: {} instead of rdoc_options: []
yaml = <<~YAML
Expand Down