From 991f17eaecc5ff5c4f233d8704edbd889724461b Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Sun, 20 Sep 2026 23:05:08 +0900 Subject: [PATCH 01/18] Set non-exitent command to RUBY_DUMP_AST Once all `*.rbinc` files have been generated, the command should not be required anymore. --- .github/actions/setup/directories/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup/directories/action.yml b/.github/actions/setup/directories/action.yml index 4eed78f49e5dcd..247f24236e6b6a 100644 --- a/.github/actions/setup/directories/action.yml +++ b/.github/actions/setup/directories/action.yml @@ -170,7 +170,7 @@ runs: run: | ruby tool/missing-baseruby.bat --verbose bash tool/gen-sources.bash up - echo RUBY_DUMP_AST=true >> "$GITHUB_ENV" + echo RUBY_DUMP_AST=./dump_ast-required-unexpectedly >> "$GITHUB_ENV" - if: steps.which.outputs.sudo shell: bash From 466256eb49e478e7cfdcdc68a34bb581cfa1fa9a Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 21 Sep 2026 00:15:10 +0900 Subject: [PATCH 02/18] Tarballs should be able to build without dump_ast --- .github/workflows/tarball-macos.yml | 1 + .github/workflows/tarball-non-development.yml | 1 + .github/workflows/tarball-ubuntu.yml | 1 + .github/workflows/tarball-windows.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/tarball-macos.yml b/.github/workflows/tarball-macos.yml index d248338b916d6c..7cff44a8d34d59 100644 --- a/.github/workflows/tarball-macos.yml +++ b/.github/workflows/tarball-macos.yml @@ -45,6 +45,7 @@ jobs: env: ARCHNAME: ${{ inputs.archname }} PREFIX: ${{ matrix.prefix || '/usr/local' }} + RUBY_DUMP_AST: ./dump_ast-required-unexpectedly steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/tarball-non-development.yml b/.github/workflows/tarball-non-development.yml index db6230b301dd9c..cecc1a4bf3ce85 100644 --- a/.github/workflows/tarball-non-development.yml +++ b/.github/workflows/tarball-non-development.yml @@ -18,6 +18,7 @@ jobs: runs-on: ubuntu-24.04 env: ruby_prefix: /tmp/ruby-snapshot + RUBY_DUMP_AST: ./dump_ast-required-unexpectedly steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/tarball-ubuntu.yml b/.github/workflows/tarball-ubuntu.yml index 0471e1b6efc77b..8cb0936b81fb4b 100644 --- a/.github/workflows/tarball-ubuntu.yml +++ b/.github/workflows/tarball-ubuntu.yml @@ -31,6 +31,7 @@ jobs: runs-on: ${{ matrix.os }} env: ARCHNAME: ${{ inputs.archname }} + RUBY_DUMP_AST: ./dump_ast-required-unexpectedly steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/tarball-windows.yml b/.github/workflows/tarball-windows.yml index 0f34ee06ee11e5..e1523dcc17f199 100644 --- a/.github/workflows/tarball-windows.yml +++ b/.github/workflows/tarball-windows.yml @@ -44,6 +44,7 @@ jobs: OS_VER: windows-${{ matrix.os }} VCPKG_DEFAULT_TRIPLET: x64-windows FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json + RUBY_DUMP_AST: ./dump_ast-required-unexpectedly NoDefaultCurrentDirectoryInExePath: 1 steps: - run: md build From 37e5c08ba7796ebb49d7047403afdb72769de55f Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Sun, 20 Sep 2026 11:16:41 -0500 Subject: [PATCH 03/18] [DOC] Harmonize owned? methods --- file.c | 50 ++++++++++++++++++++++++++++++++++++--------- pathname_builtin.rb | 4 ++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/file.c b/file.c index 412bc2e874368d..e6c379e521a389 100644 --- a/file.c +++ b/file.c @@ -2381,14 +2381,27 @@ rb_file_size_p(VALUE obj, VALUE fname) } /* + * :markup: markdown + * * call-seq: - * File.owned?(file_name) -> true or false + * File.owned?(object) -> true or false * - * Returns true if the named file exists and the - * effective user id of the calling process is the owner of - * the file. + * Returns whether the given `object` represents a filesystem entry or IO object + * that exists and is owned by the user of the current process: + * + * ```ruby + * filepath = 'doc/t.tmp' + * File.write(filepath, 'foo') + * File.owned?(filepath) # => true + * File.delete(filepath) # Clean up. + * dirpath = 'doc/tmp' + * Dir.mkdir(dirpath) + * File.owned?(dirpath) # => true + * Dir.rmdir(dirpath) # Clean up. + * File.owned?($stdin) # => true + * File.owned?('/etc') # => false + * ``` * - * _file_name_ can be an IO object. */ static VALUE @@ -6930,14 +6943,31 @@ rb_stat_c(VALUE obj) } /* + * :markup: markdown + * * call-seq: - * stat.owned? -> true or false + * owned? -> true or false * - * Returns true if the effective user id of the process is - * the same as the owner of stat. + * Returns whether `self` represents a filesystem entry that, + * at the time `self` was created, + * existed and was owned by the user of the current process; + * see [Snapshot](rdoc-ref:File::Stat@Snapshot): * - * File.stat("testfile").owned? #=> true - * File.stat("/etc/passwd").owned? #=> false + * ```ruby + * filepath = 'doc/t.tmp' + * File.write(filepath, 'foo') + * filestat = File.stat(filepath) + * filestat.owned? # => true + * File.delete(filepath) + * filestat.owned? # => true # Snapshot unchanged. + * dirpath = 'doc/tmp' + * Dir.mkdir(dirpath) + * dirstat = File.stat(dirpath) + * dirstat.owned? # => true + * Dir.rmdir(dirpath) + * dirstat.owned? # => true # Snapshot unchanged. + * File.stat('/etc').owned? # => false + * ``` * */ diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 1a200256bd2b06..9fe12c0f15aa97 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -2527,11 +2527,11 @@ def socket?() FileTest.socket?(@path) end # pn = Pathname('doc/t.tmp') # pn.write('foo') # pn.owned? # => true - # pn.delete + # pn.delete # Clean up. # pn = Pathname('doc/tmp') # pn.mkdir # pn.owned? # => true - # pn.rmdir + # pn.rmdir # Clean up. # Pathname('/etc').owned? # => false # ``` # From 741619cb9ee2a664d0b109d1a20a842f3e65c749 Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Sun, 20 Sep 2026 14:12:18 -0500 Subject: [PATCH 04/18] [DOC] Harmonize pipe? methods --- file.c | 35 +++++++++++++++++++++++++++-------- pathname_builtin.rb | 4 ++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/file.c b/file.c index e6c379e521a389..33c1da374861eb 100644 --- a/file.c +++ b/file.c @@ -1866,14 +1866,22 @@ rb_file_directory_p(VALUE obj, VALUE fname) } /* + * :markup: markdown + * * call-seq: - * File.pipe?(filepath) -> true or false + * File.pipe?(path) -> true or false * - * Returns +true+ if +filepath+ points to a pipe, +false+ otherwise: + * Returns whether the entry at the given `path` is a pipe: * - * File.mkfifo('tmp/fifo') - * File.pipe?('tmp/fifo') # => true - * File.pipe?('t.txt') # => false + * ```ruby + * File.pipe?('doc/syntax/') # => false # Directory. + * File.pipe?('doc/maintainers.md') # => false # Regular file. + * File.pipe?('nosuch') # => false # Non-existent. + * path = '/tmp/foo' + * File.mkfifo(path) + * File.pipe?(path) # => true + * File.delete(path) # Clean up. + * ``` * */ @@ -6830,11 +6838,22 @@ rb_stat_d(VALUE obj) } /* + * :markup: markdown + * * call-seq: - * stat.pipe? -> true or false + * stat.pipe? -> true or false + * + * Returns whether the entry at the path in `self` is a pipe: + * + * ```ruby + * File.stat('doc/syntax/').pipe? # => false # Directory . + * File.stat('doc/maintainers.md').pipe? # => false # Regular file. + * path = '/tmp/foo' + * File.mkfifo(path) + * File.stat(path).pipe? # => true + * File.delete(path) # Clean up. + * ``` * - * Returns true if the operating system supports pipes and - * stat is a pipe; false otherwise. */ static VALUE diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 9fe12c0f15aa97..3d99803733b077 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -2479,14 +2479,14 @@ def file?() FileTest.file?(@path) end # call-seq: # pipe? -> true or false # - # Returns whether entry at the path in `self` is a pipe: + # Returns whether the entry at the path in `self` is a pipe: # # ```ruby + # Pathname('.').pipe? # => false # path = '/tmp/foo' # File.mkfifo(path) # pn = Pathname(path) # => # # pn.pipe? # => true - # Pathname('.').pipe? # => false # pn.delete # Clean up. # ``` # From c80608341de98e96716a7a7b7e9db0cbfdc07483 Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Sun, 20 Sep 2026 14:45:33 -0500 Subject: [PATCH 05/18] [DOC] Harmonize readable? methods --- file.c | 39 ++++++++++++++++++++++++++++++--------- pathname_builtin.rb | 3 ++- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/file.c b/file.c index 33c1da374861eb..1ecd941b8b572b 100644 --- a/file.c +++ b/file.c @@ -2090,14 +2090,25 @@ rb_file_exist_p(VALUE obj, VALUE fname) } /* + * :markup: markdown + * * call-seq: - * File.readable?(file_name) -> true or false + * File.readable?(path) -> true or false * - * Returns true if the named file is readable by the effective - * user and group id of this process. See eaccess(3). + * Returns whether the entry at the given `path` + * exists and is readable by the owner and group of the current process; + * see [Permissions](rdoc-ref:file/filesystem_modes.md@Permissions): + * + * ```ruby + * path = '/tmp/secret.txt' + * File.write(path, 'foo') + * File.readable?(path) # => true + * File.chmod(0o000, path) + * File.readable?(path) # => false + * File.delete(path) # Clean up. + * File.readable?('nosuch') # => false + * ``` * - * Note that some OS-level security features may cause this to return true - * even though the file is not readable by the effective user/group. */ static VALUE @@ -7030,13 +7041,23 @@ rb_stat_grpowned(VALUE obj) } /* + * :markup: markdown + * * call-seq: - * stat.readable? -> true or false + * readable? -> true or false * - * Returns true if stat is readable by the - * effective user id of this process. + * Returns whether the entry represented by `self` + * exists and is readable by the owner and group of the current process; + * see [Permissions](rdoc-ref:file/filesystem_modes.md@Permissions): * - * File.stat("testfile").readable? #=> true + * ```ruby + * path = '/tmp/secret.txt' + * File.write(path, 'foo') + * File.stat(path).readable? # => true + * File.chmod(0o000, path) + * File.stat(path).readable? # => false + * File.delete(path) # Clean up. + * ``` * */ diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 3d99803733b077..33ebc0337509f0 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -2543,7 +2543,8 @@ def owned?() FileTest.owned?(@path) end # readable? -> true or false # # Returns whether the entry at the path in `self` - # is readable by the owner and group of the current process: + # exists and is readable by the owner and group of the current process; + # see [Permissions](rdoc-ref:file/filesystem_modes.md@Permissions): # # ```ruby # pn = Pathname('/tmp/secret.txt') From 961d6b15f42551ecb52895c805d5f19d28bcfcb1 Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Sun, 20 Sep 2026 15:50:11 -0500 Subject: [PATCH 06/18] [DOC] Harmonize readable_real? methods --- file.c | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/file.c b/file.c index 1ecd941b8b572b..69259fce9f3bc1 100644 --- a/file.c +++ b/file.c @@ -2118,14 +2118,13 @@ rb_file_readable_p(VALUE obj, VALUE fname) } /* - * call-seq: - * File.readable_real?(file_name) -> true or false + * :markup: markdown * - * Returns true if the named file is readable by the real - * user and group id of this process. See access(3). + * call-seq: + * File.readable_real?(path) -> true or false * - * Note that some OS-level security features may cause this to return true - * even though the file is not readable by the real user/group. + * Like File.readable?, but checks against the real user and group ids + * instead of the effective ids. */ static VALUE @@ -7084,14 +7083,13 @@ rb_stat_r(VALUE obj) } /* + * :markup: markdown + * * call-seq: * stat.readable_real? -> true or false * - * Returns true if stat is readable by the real - * user id of this process. - * - * File.stat("testfile").readable_real? #=> true - * + * Like #readable?, but checks against the real user and group ids + * instead of the effective ids. */ static VALUE From f0281b7a474ec3c8aa95ab37e768c2d108afa01b Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Sun, 20 Sep 2026 16:36:41 -0500 Subject: [PATCH 07/18] [DOC] Harmonize readlink methods --- file.c | 8 ++++---- pathname_builtin.rb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/file.c b/file.c index 69259fce9f3bc1..9088990792f1bc 100644 --- a/file.c +++ b/file.c @@ -3882,11 +3882,11 @@ rb_file_s_symlink(VALUE klass, VALUE from, VALUE to) * by the [symbolic link](rdoc-ref:file/symbolic_links.md) at `link_path`: * * ```ruby - * filepath = 'README.md' - * linkpath = 'foo' + * filepath = 'doc/maintainers.md' + * linkpath = '/tmp/link' * File.symlink(filepath, linkpath) - * File.readlink(linkpath) # => "README.md" - * File.unlink(linkpath) # Clean up. + * File.readlink(linkpath) # => "doc/maintainers.md" + * File.delete(linkpath) # Clean up. * ``` * * Raises Errno::EINVAL if the entry referenced by `link_path` diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 33ebc0337509f0..ad1700b8dc5cd1 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -1776,11 +1776,11 @@ def open(...) # :yield: file # at the path stored in `self`: # # ```ruby - # file_pn = Pathname('README.md') - # link_pn = Pathname('foo') + # file_pn = Pathname('doc/maintainers.md') + # link_pn = Pathname('/tmp/link') # link_pn.make_symlink(file_pn) - # link_pn.readlink # => # - # link_pn.unlink # Clean up. + # link_pn.readlink # => # + # link_pn.delete # Clean up. # ``` # # Raises Errno::EINVAL if the path in `self` is not the path to a symbolic link. From 681d0f1425cdb6e206163e73ffd9dfd468e62133 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Sun, 20 Sep 2026 21:03:08 +0900 Subject: [PATCH 08/18] Fix crash in String#tr when hash modified String#tr can crash if keys are removed from the translation hash since we stack allocate a buffer pairs. If entries are deleted during runtime, then we won't fill the pairs buffer which can crash because it will be reading uninitialized values out of pairs. The following script demonstrates the crash: h = {} obj = Object.new obj.define_singleton_method(:to_str) do h.clear "a" end h[obj] = "x" ("b".."z").each { |c| h[c] = (c.ord + 1).chr } puts "ab".tr!(h) --- string.c | 5 +++++ test/ruby/test_string.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/string.c b/string.c index 7c8f7de12c3db9..116c19e320d344 100644 --- a/string.c +++ b/string.c @@ -9794,6 +9794,11 @@ tr_trans_pairs(VALUE str, VALUE pairs_val) rb_hash_foreach(pairs_val, tr_trans_pairs_coerce_i, (VALUE)&coerce_args); rb_encoding *e1 = coerce_args.enc; + /* Keys could be deleted from pairs_val during rb_hash_foreach when coercing + * the keys/values, so we need to update pairs_count to the number of pairs we + * were actually able to extract from pairs_val. */ + pairs_count = coerce_args.index; + VALUE hash = 0; const unsigned char *sstart = (unsigned char *)RSTRING_PTR(str); diff --git a/test/ruby/test_string.rb b/test/ruby/test_string.rb index 0220a5f1e6803f..550792283906c0 100644 --- a/test/ruby/test_string.rb +++ b/test/ruby/test_string.rb @@ -3013,6 +3013,18 @@ def test_tr_hash assert_equal(expected, actual) end + def test_tr_hash_modify + replacements = {} + obj = Object.new + obj.define_singleton_method(:to_str) do + replacements.clear + "a" + end + replacements[obj] = "x" + ("b".."z").each { |c| replacements[c] = (c.ord + 1).chr } + assert_equal(S("xb"), S("ab").tr(replacements)) + end + def test_tr! a = S("hello") b = a.dup From 26f43967c882401452166b0709bd5dbd909366d6 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Sun, 14 Jun 2026 10:07:31 +0200 Subject: [PATCH 09/18] [ruby/fileutils] Fix relative symlink target generation https://github.com/ruby/fileutils/commit/7de921e801 --- lib/fileutils.rb | 4 ++-- test/fileutils/test_fileutils.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/fileutils.rb b/lib/fileutils.rb index 9c894b7a30e99b..d2c13365fed1d5 100644 --- a/lib/fileutils.rb +++ b/lib/fileutils.rb @@ -715,7 +715,7 @@ def cp_lr(src, dest, noop: nil, verbose: nil, # Keyword arguments: # # - force: true - overwrites +dest+ if it exists. - # - relative: false - create links relative to +dest+. + # - relative: true - create links relative to +dest+. # - noop: true - does not create links. # - verbose: true - prints an equivalent command: # @@ -783,7 +783,7 @@ def ln_sr(src, dest, target_directory: true, force: nil, noop: nil, verbose: nil n = real_ddirs.size - i n -= 1 unless target_directory link2 = fu_clean_components(*Array.new([n, 0].max, '..'), *real_sdirs[i..-1]) - link1 = link2 if link1.size > link2.size + link1 = link2 if !link2.empty? and link1.size > link2.size end s = File.join(link1) fu_output_message [cmd, s, d].flatten.join(' ') if verbose diff --git a/test/fileutils/test_fileutils.rb b/test/fileutils/test_fileutils.rb index 92308d95573206..3a2ad323027209 100644 --- a/test/fileutils/test_fileutils.rb +++ b/test/fileutils/test_fileutils.rb @@ -979,6 +979,32 @@ def test_ln_s end end if have_symlink? and !no_broken_symlink? + def test_ln_s_relative_to_symlinked_directory + mkdir_p 'tmp/symlink_dir/.dotfiles/zsh' + mkdir_p 'tmp/symlink_dir/.config' + + src = File.expand_path('tmp/symlink_dir/.dotfiles/zsh') + dest = File.expand_path('tmp/symlink_dir/.config/zsh') + + assert_output_lines(["ln -s ../.dotfiles/zsh #{dest}"]) { + ln_s src, dest, relative: true, verbose: true, noop: true + } + + ln_s src, dest, relative: true + assert_file.symlink?(dest) + assert_equal '../.dotfiles/zsh', File.readlink(dest) + + lnfname = File.join(dest, 'zsh') + assert_output_lines(["ln -s ../../.dotfiles/zsh #{lnfname}"]) { + ln_s src, dest, relative: true, verbose: true, noop: true + } + + ln_s src, dest, relative: true + assert_file.symlink?(lnfname) + assert_equal '../../.dotfiles/zsh', File.readlink(lnfname) + assert_equal File.realpath(src), File.realpath(lnfname) + end if have_symlink? and !no_broken_symlink? + def test_ln_s_broken_symlink assert_nothing_raised { ln_s 'symlink', 'tmp/symlink' From 0cb17e33e22221ae6e9ae0197d44d8b02c95650c Mon Sep 17 00:00:00 2001 From: Nikita Vasilevsky Date: Tue, 21 Jul 2026 17:09:37 -0400 Subject: [PATCH 10/18] [ruby/fileutils] Remove unused fu_copy_stream0 helper fu_copy_stream0 has had no caller since b26846bf replaced both uses with direct IO.copy_stream calls in 2008. The retained private wrapper is unreachable. https://github.com/ruby/fileutils/commit/feba38f651 --- lib/fileutils.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/fileutils.rb b/lib/fileutils.rb index d2c13365fed1d5..85b069bc875c0b 100644 --- a/lib/fileutils.rb +++ b/lib/fileutils.rb @@ -2066,10 +2066,6 @@ def fu_windows?; true end #:nodoc: def fu_windows?; false end #:nodoc: end - def fu_copy_stream0(src, dest, blksize = nil) #:nodoc: - IO.copy_stream(src, dest) - end - def fu_stream_blksize(*streams) #:nodoc: streams.each do |s| next unless s.respond_to?(:stat) From 508ee642640375ade6cd7e8ad59d16e376e85138 Mon Sep 17 00:00:00 2001 From: TAKANO Mitsuhiro Date: Mon, 8 Jun 2026 17:01:35 +0900 Subject: [PATCH 11/18] [ruby/fileutils] Skip chown tests when the target group is not assignable In user-namespace environments (e.g. ChromeOS Crostini) getgroups(2) can report supplementary groups such as the overflow GID 65534 (nobody) that a non-root process cannot actually chgrp a file to. TestFileUtils#setup built @groups straight from `[Process.gid] | Process.groups`, so the chown tests tried to chgrp to such a group and failed with EPERM instead of being skipped. Filter @groups with a one-time capability probe down to the groups the process can actually assign, so the affected tests skip via their existing `return unless @groups[1]` guard when there is no usable second group. https://github.com/ruby/fileutils/commit/8dc0266054 Co-Authored-By: Claude Opus 4.8 --- test/fileutils/test_fileutils.rb | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/fileutils/test_fileutils.rb b/test/fileutils/test_fileutils.rb index 3a2ad323027209..54ae2294ab36c5 100644 --- a/test/fileutils/test_fileutils.rb +++ b/test/fileutils/test_fileutils.rb @@ -41,6 +41,32 @@ def have_file_perm? /mswin|mingw|bcc|emx/ !~ RUBY_PLATFORM end + @@assignable_groups = nil + + # Filter the given group IDs down to those the current process can actually + # assign to a file with chown. Some environments (e.g. user-namespace + # containers) report supplementary groups such as the overflow GID + # (65534/nobody) that the kernel refuses to chgrp to; without this the + # group-ownership tests would fail with EPERM instead of being skipped. + def assignable_groups(groups) + @@assignable_groups ||= {} + groups.select do |gid| + @@assignable_groups.fetch(gid) do + Dir.mktmpdir("fileutils") do |dir| + probe = File.join(dir, "probe") + File.write(probe, "") + @@assignable_groups[gid] = + begin + File.chown(nil, gid, probe) + true + rescue Errno::EPERM + false + end + end + end + end + end + @@have_symlink = nil def have_symlink? @@ -182,7 +208,7 @@ def mymkdir(path) def setup @prevdir = Dir.pwd - @groups = [Process.gid] | Process.groups if have_file_perm? + @groups = assignable_groups([Process.gid] | Process.groups) if have_file_perm? tmproot = @tmproot = Dir.mktmpdir "fileutils" Dir.chdir tmproot my_rm_rf 'data'; mymkdir 'data' From be820c30cb188b043976f806da776896deb836d5 Mon Sep 17 00:00:00 2001 From: Jiaying Song Date: Thu, 17 Jul 2025 10:57:53 +0800 Subject: [PATCH 12/18] [ruby/fileutils] Skip test_rm_r_no_permissions test under root Skip the test_rm_r_no_permissions test under the root user, as deletion always succeeds. Signed-off-by: Jiaying Song https://github.com/ruby/fileutils/commit/3c831389c5 --- test/fileutils/test_fileutils.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fileutils/test_fileutils.rb b/test/fileutils/test_fileutils.rb index 54ae2294ab36c5..2e68b10abeaa06 100644 --- a/test/fileutils/test_fileutils.rb +++ b/test/fileutils/test_fileutils.rb @@ -795,7 +795,7 @@ def test_rm_r_pathname def test_rm_r_no_permissions check_singleton :rm_rf - return if /mswin|mingw/ =~ RUBY_PLATFORM + return if /mswin|mingw/ =~ RUBY_PLATFORM || root_in_posix? mkdir 'tmpdatadir' touch 'tmpdatadir/tmpdata' From 647e6e61c1c9588d8debf6736f368fdfa291ca7b Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 21 Sep 2026 12:06:42 +0900 Subject: [PATCH 13/18] [ruby/fileutils] Remove redundant condition Since `Process.uid` returns `0` always on mingw or mswin, `root_in_posix?` returns `true` too. https://github.com/ruby/fileutils/commit/d18fbf0c56 --- test/fileutils/test_fileutils.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fileutils/test_fileutils.rb b/test/fileutils/test_fileutils.rb index 2e68b10abeaa06..a07841266c95ff 100644 --- a/test/fileutils/test_fileutils.rb +++ b/test/fileutils/test_fileutils.rb @@ -795,7 +795,7 @@ def test_rm_r_pathname def test_rm_r_no_permissions check_singleton :rm_rf - return if /mswin|mingw/ =~ RUBY_PLATFORM || root_in_posix? + return if root_in_posix? mkdir 'tmpdatadir' touch 'tmpdatadir/tmpdata' From d4d078672943ffd3c344c431c631a8666a5a3226 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 21 Sep 2026 11:41:33 +0900 Subject: [PATCH 14/18] Fix out-of-bounds access in String#byteindex/byterindex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the source string is modified when converting offset to an integer, then there could be an out-of-bounds access because the length of the string is captured before to_int is called. For example, the following script triggers an ASAN error: s = "héllo" * 1_000_000 obj = Object.new obj.define_singleton_method(:to_int) do s.replace("é" * 1000) 450000 end p s.byteindex("l", obj) --- string.c | 7 ++++--- test/ruby/test_string.rb | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/string.c b/string.c index 116c19e320d344..c890a03222b546 100644 --- a/string.c +++ b/string.c @@ -4801,8 +4801,8 @@ rb_str_byteindex_m(int argc, VALUE *argv, VALUE str) long pos; if (rb_scan_args(argc, argv, "11", &sub, &initpos) == 2) { - long slen = RSTRING_LEN(str); pos = NUM2LONG(initpos); + long slen = RSTRING_LEN(str); if (pos < 0 ? (pos += slen) < 0 : pos > slen) { if (RB_TYPE_P(sub, T_REGEXP)) { rb_backref_set(Qnil); @@ -5076,10 +5076,11 @@ rb_str_byterindex_m(int argc, VALUE *argv, VALUE str) { VALUE sub; VALUE initpos; - long pos, len = RSTRING_LEN(str); + long pos; if (rb_scan_args(argc, argv, "11", &sub, &initpos) == 2) { pos = NUM2LONG(initpos); + long len = RSTRING_LEN(str); if (pos < 0 && (pos += len) < 0) { if (RB_TYPE_P(sub, T_REGEXP)) { rb_backref_set(Qnil); @@ -5089,7 +5090,7 @@ rb_str_byterindex_m(int argc, VALUE *argv, VALUE str) if (pos > len) pos = len; } else { - pos = len; + pos = RSTRING_LEN(str); } str_ensure_byte_pos(str, pos); diff --git a/test/ruby/test_string.rb b/test/ruby/test_string.rb index 550792283906c0..8e2b3f7e9bb5fd 100644 --- a/test/ruby/test_string.rb +++ b/test/ruby/test_string.rb @@ -4242,6 +4242,16 @@ def o.to_str; "bar"; end assert !1000.times.any? {s.byteindex("", 100_000_000)} end + def test_byteindex_modify_source + s = S("héllo" * 1000) + obj = Object.new + obj.define_singleton_method(:to_int) do + s.replace("é" * 50) + 4500 + end + assert_nil(s.byteindex("l", obj)) + end + def test_byterindex assert_byterindex(3, S("hello"), ?l) assert_byterindex(6, S("ell, hello"), S("ell")) @@ -4296,6 +4306,16 @@ def o.to_str; "bar"; end assert_byterindex(nil, S(""), S("こんにちは")) end + def test_byterindex_modify_source + s = S("héllo" * 1000) + obj = Object.new + obj.define_singleton_method(:to_int) do + s.replace("é" * 50) + 4500 + end + assert_nil(s.byterindex("l", obj)) + end + def test_bytesplice assert_bytesplice_raise(IndexError, S("hello"), -6, 0, "bye") assert_bytesplice_result("byehello", S("hello"), -5, 0, "bye") From cf2b80c06a4dd7530ad7d1bdfd02e7c47607fc7d Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 21 Sep 2026 17:53:36 +0900 Subject: [PATCH 15/18] [ruby/fileutils] Workaround for symlink bug on Windows Fixed by ruby/ruby#17545. https://github.com/ruby/fileutils/commit/9e957c2330 --- test/fileutils/test_fileutils.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/fileutils/test_fileutils.rb b/test/fileutils/test_fileutils.rb index a07841266c95ff..43ffc7817ccbf7 100644 --- a/test/fileutils/test_fileutils.rb +++ b/test/fileutils/test_fileutils.rb @@ -1021,6 +1021,13 @@ def test_ln_s_relative_to_symlinked_directory assert_equal '../.dotfiles/zsh', File.readlink(dest) lnfname = File.join(dest, 'zsh') + + if /mingw|mswin/ =~ RUBY_PLATFORM + unless // =~ IO.popen({"DIRCMD"=>nil}, "dir zsh", chdir: File.dirname(dest), &:read) + omit "[Bug #22338]" + end + end + assert_output_lines(["ln -s ../../.dotfiles/zsh #{lnfname}"]) { ln_s src, dest, relative: true, verbose: true, noop: true } From 09a5f2ef36170c678922680714ca4b4ec3c65624 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 21 Sep 2026 18:00:07 +0900 Subject: [PATCH 16/18] [ruby/fileutils] Add tests for `FileUtils.touch` Fix https://github.com/ruby/fileutils/pull/177 https://github.com/ruby/fileutils/commit/86278df2f7 --- test/fileutils/test_fileutils.rb | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/fileutils/test_fileutils.rb b/test/fileutils/test_fileutils.rb index 43ffc7817ccbf7..27511c2f50d0ea 100644 --- a/test/fileutils/test_fileutils.rb +++ b/test/fileutils/test_fileutils.rb @@ -2059,6 +2059,37 @@ def test_touch check_singleton :touch end + def test_touch_verbose + assert_output_lines(["touch file"]) do + touch('file', verbose: true, noop: true) + end + assert_output_lines(["touch -c file"]) do + touch('file', verbose: true, noop: true, nocreate: true) + end + t = Time.new(2026, 5, 4, 3, 2, 1) + assert_output_lines(["touch -t 202605040302.01 file"]) do + touch('file', verbose: true, noop: true, mtime: t) + end + end + + def test_touch_create + t0 = Time.now - 10 # discrepancies caused by remote file systems? + assert_file.not_exist?('file') + assert_raise(Errno::ENOENT) {touch('file', nocreate: true)} + assert_file.not_exist?('file') + touch('file') + assert_file.exist?('file') + t = File.mtime('file') + assert_operator(t, :>=, t0) + assert_operator(t, :<=, Time.now + 10) + end + + def test_touch_mtime + t = Time.new(2026, 5, 4, 3, 2, 1) + touch('file', mtime: t) + assert_equal(t, File.mtime('file')) + end + def test_collect_methods end From 8befacf9e6c898a0f79dcb53bdd2e4cea24e0d1c Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Wed, 16 Sep 2026 22:13:13 +0100 Subject: [PATCH 17/18] Guard against oob read in rb_ary_aref1 When the array is shrunk during rb_arithmetic_sequence_beg_len_step, ary_subseq_len returns -1. Return an empty array instead of passing the negative length to ary_make_partial or ary_make_partial_step. [Bug #22325] --- array.c | 2 +- test/ruby/test_array.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/array.c b/array.c index adb61a0d4cdeb9..95d817dca12da6 100644 --- a/array.c +++ b/array.c @@ -1943,7 +1943,7 @@ rb_ary_aref1(VALUE ary, VALUE arg) default: if (step == 0) rb_raise(rb_eArgError, "slice step cannot be zero"); len = ary_subseq_len(ary, beg, len); - if (len == 0) return ary_new(klass, 0); + if (len <= 0) return ary_new(klass, 0); if (step == 1) return ary_make_partial(ary, klass, beg, len); return ary_make_partial_step(ary, klass, beg, len, step); } diff --git a/test/ruby/test_array.rb b/test/ruby/test_array.rb index 62373ab2f06722..d529788edd1722 100644 --- a/test/ruby/test_array.rb +++ b/test/ruby/test_array.rb @@ -1833,6 +1833,33 @@ def test_slice_out_of_range assert_equal([100], a.slice(-1, 1_000_000_000)) end + def test_slice_shrinking_array_by_to_int + bug22325 = '[Bug #22325]' + cls = Class.new(Numeric) do + attr_reader :val + def initialize(ary, val) + @ary = ary + @val = val + end + def <=>(other) + val <=> (other.is_a?(self.class) ? other.val : other) + end + def to_int + @ary.clear + val + end + def coerce(other) + [other, val] + end + end + + ary = @cls[*(1..100).to_a] + assert_equal([], ary[Range.new(cls.new(ary, 50), cls.new(ary, 60))], bug22325) + + ary.replace((1..100).to_a) + assert_equal([], ary[Range.new(cls.new(ary, 50), cls.new(ary, 60)).step(2)], bug22325) + end + def test_slice_gc_compact_stress EnvUtil.under_gc_compact_stress { assert_equal([1, 2, 3, 4, 5], (0..10).to_a[1, 5]) } EnvUtil.under_gc_compact_stress do From eb08b74558ae5c8ff317a544197f871499d1b7bd Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 21 Sep 2026 19:07:28 +0900 Subject: [PATCH 18/18] [ruby/mmtk] Keep track of pending pages in RubyHeapTrigger We need to keep track of pending pages in RubyHeapTrigger otherwise a large allocation may cause infinite number of GCs to be ran since it will always appear that we have enough space in the heap (and thus the heap won't grow) but still not have enough for the allocation. https://github.com/ruby/mmtk/commit/dd8034050d --- gc/mmtk/src/heap/ruby_heap_trigger.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/gc/mmtk/src/heap/ruby_heap_trigger.rs b/gc/mmtk/src/heap/ruby_heap_trigger.rs index e3d7b05c93d1d9..b152a31d944b0d 100644 --- a/gc/mmtk/src/heap/ruby_heap_trigger.rs +++ b/gc/mmtk/src/heap/ruby_heap_trigger.rs @@ -27,6 +27,7 @@ pub struct RubyHeapTriggerConfig { pub struct RubyHeapTrigger { /// Target number of heap pages target_heap_pages: AtomicUsize, + pending_pages: AtomicUsize, } impl GCTriggerPolicy for RubyHeapTrigger { @@ -40,10 +41,20 @@ impl GCTriggerPolicy for RubyHeapTrigger { plan.collection_required(space_full, space) } + fn on_pending_allocation(&self, pages: usize) { + self.pending_pages.fetch_add(pages, Ordering::SeqCst); + } + fn on_pause_end(&self, mmtk: &'static MMTK) { - if let Some(plan) = mmtk.get_plan().generational() { - if plan.is_current_gc_nursery() { - return; + let pending_pages = self.pending_pages.swap(0, Ordering::SeqCst); + + // Nursery GCs don't resize the heap, unless a failed allocation is + // waiting on us to make room for it. + if pending_pages == 0 { + if let Some(plan) = mmtk.get_plan().generational() { + if plan.is_current_gc_nursery() { + return; + } } } @@ -53,14 +64,17 @@ impl GCTriggerPolicy for RubyHeapTrigger { (used_pages as f64 * (1.0 + Self::get_config().heap_pages_min_ratio)) as usize; let target_max = (used_pages as f64 * (1.0 + Self::get_config().heap_pages_max_ratio)) as usize; + // Grow the heap by the goal ratio over the live size, plus whatever + // is needed to fit allocations that failed and triggered this GC. let new_target = (((used_pages as f64) * (1.0 + Self::get_config().heap_pages_goal_ratio)) as usize) + .saturating_add(pending_pages) .clamp( Self::get_config().min_heap_pages, Self::get_config().max_heap_pages, ); - if used_pages < target_min || used_pages > target_max { + if pending_pages > 0 || used_pages < target_min || used_pages > target_max { self.target_heap_pages.store(new_target, Ordering::Relaxed); } } @@ -88,6 +102,7 @@ impl Default for RubyHeapTrigger { Self { target_heap_pages: AtomicUsize::new(min_heap_pages), + pending_pages: AtomicUsize::new(0), } } } @@ -122,6 +137,7 @@ mod tests { RubyHeapTrigger { target_heap_pages: AtomicUsize::new(target_heap_pages), + pending_pages: AtomicUsize::new(0), } }