From ee786598f10c6def72265fb7105fe505f563f52f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 28 Jun 2026 19:42:43 +0900 Subject: [PATCH 01/12] [Bug] win32: create directory symlinks for relative directory targets File.symlink decided whether to create a directory symlink by calling GetFileAttributesW on the target as given, which resolves a relative target against the current directory instead of the link's directory. When the two differ, a relative target pointing at a directory became a file symlink, so Dir operations on it failed. Resolve relative targets against the link's directory before testing for a directory. Co-Authored-By: Claude Opus 4.8 --- test/ruby/test_file_exhaustive.rb | 22 ++++++++++++++++++++++ win32/win32.c | 27 ++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/test/ruby/test_file_exhaustive.rb b/test/ruby/test_file_exhaustive.rb index 6e7973897c7960..77e4ab1a6277dd 100644 --- a/test/ruby/test_file_exhaustive.rb +++ b/test/ruby/test_file_exhaustive.rb @@ -715,6 +715,28 @@ def test_symlink assert_raise(Errno::EEXIST) { File.symlink(utf8_file, utf8_file) } end + def test_symlink_to_relative_directory + # A relative target is interpreted relative to the link's directory, not the + # current directory. A relative target pointing at a directory must produce + # a directory symlink even when the current directory differs from the link's + # directory; otherwise Dir operations on the link fail (Windows). + Dir.mktmpdir(__method__.to_s) do |tmpdir| + Dir.chdir(tmpdir) do + Dir.mkdir("subdir") + Dir.mkdir(File.join("subdir", "target")) + link = File.join("subdir", "link") + begin + File.symlink("target", link) + rescue NotImplementedError, Errno::EACCES, Errno::EPERM => e + omit e.message + end + assert_file.symlink?(link) + assert_file.directory?(link) + assert(Dir.exist?(link), "relative directory symlink should be a directory") + end + end + end + def test_utime t = Time.local(2000) File.utime(t + 1, t + 2, zerofile) diff --git a/win32/win32.c b/win32/win32.c index e3a3df71f6ce34..dfc96ea8a3d7c2 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -5284,8 +5284,33 @@ w32_symlink(UINT cp, const char *src, const char *link) MultiByteToWideChar(cp, 0, src, -1, wsrc, len1); MultiByteToWideChar(cp, 0, link, -1, wlink, len2); translate_wchar(wsrc, L'/', L'\\'); + translate_wchar(wlink, L'/', L'\\'); - atts = GetFileAttributesW(wsrc); + /* A relative target is interpreted relative to the directory of the link, + not the current directory. Resolve it there to decide whether to create + a directory symlink; otherwise a relative target pointing at a directory + would wrongly become a file symlink when the current directory differs + from the link's directory. */ + { + WCHAR *sep; + int absolute = + (((wsrc[0] >= L'A' && wsrc[0] <= L'Z') || + (wsrc[0] >= L'a' && wsrc[0] <= L'z')) && wsrc[1] == L':') || + (wsrc[0] == L'\\' && wsrc[1] == L'\\'); + if (!absolute && (sep = wcsrchr(wlink, L'\\')) != NULL) { + VALUE buf2; + size_t dirlen = sep - wlink + 1; + size_t srclen = wcslen(wsrc) + 1; + WCHAR *fullsrc = ALLOCV_N(WCHAR, buf2, dirlen + srclen); + memcpy(fullsrc, wlink, dirlen * sizeof(WCHAR)); + memcpy(fullsrc + dirlen, wsrc, srclen * sizeof(WCHAR)); + atts = GetFileAttributesW(fullsrc); + ALLOCV_END(buf2); + } + else { + atts = GetFileAttributesW(wsrc); + } + } if (atts != -1 && atts & FILE_ATTRIBUTE_DIRECTORY) flag = SYMBOLIC_LINK_FLAG_DIRECTORY; ret = CreateSymbolicLinkW(wlink, wsrc, flag |= create_flag); From 54f254aa41eb624429e6b884c0a6381eeeac50d0 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 11:37:50 +0900 Subject: [PATCH 02/12] win32: use MEMCPY for overflow-safe copy in w32_symlink MEMCPY checks the size calculation for overflow, unlike a raw memcpy. Co-Authored-By: Claude Opus 4.8 --- win32/win32.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/win32/win32.c b/win32/win32.c index dfc96ea8a3d7c2..aa9aabfbe9e4bb 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -5302,8 +5302,8 @@ w32_symlink(UINT cp, const char *src, const char *link) size_t dirlen = sep - wlink + 1; size_t srclen = wcslen(wsrc) + 1; WCHAR *fullsrc = ALLOCV_N(WCHAR, buf2, dirlen + srclen); - memcpy(fullsrc, wlink, dirlen * sizeof(WCHAR)); - memcpy(fullsrc + dirlen, wsrc, srclen * sizeof(WCHAR)); + MEMCPY(fullsrc, wlink, WCHAR, dirlen); + MEMCPY(fullsrc + dirlen, wsrc, WCHAR, srclen); atts = GetFileAttributesW(fullsrc); ALLOCV_END(buf2); } From 15dbb08ba8b69877c8012e0f5950f3f26a3abffe Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 11:38:10 +0900 Subject: [PATCH 03/12] [Bug] win32: do not resolve a root-relative symlink target against the link dir A target starting with a single backslash is relative to the root of the current drive, not to the link's directory. Treat any backslash-leading target (root-relative or UNC) as non-relative when deciding the directory flag; otherwise w32_symlink builds a malformed path and fails to detect the directory attribute. Co-Authored-By: Claude Opus 4.8 --- win32/win32.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/win32/win32.c b/win32/win32.c index aa9aabfbe9e4bb..1b10731fc7dc68 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -5296,7 +5296,7 @@ w32_symlink(UINT cp, const char *src, const char *link) int absolute = (((wsrc[0] >= L'A' && wsrc[0] <= L'Z') || (wsrc[0] >= L'a' && wsrc[0] <= L'z')) && wsrc[1] == L':') || - (wsrc[0] == L'\\' && wsrc[1] == L'\\'); + wsrc[0] == L'\\'; if (!absolute && (sep = wcsrchr(wlink, L'\\')) != NULL) { VALUE buf2; size_t dirlen = sep - wlink + 1; From 249de200d5be1f1d1f936e40949a6b48ac9b6e25 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 14:30:56 +0900 Subject: [PATCH 04/12] win32: rename the symlink target flag to independent The flag also covers drive-relative and root-relative targets, which are not absolute paths. "independent" describes that the target is not interpreted relative to the link's directory. Co-Authored-By: Claude Opus 4.8 --- win32/win32.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/win32/win32.c b/win32/win32.c index 1b10731fc7dc68..00a87ff226d60c 100644 --- a/win32/win32.c +++ b/win32/win32.c @@ -5293,11 +5293,11 @@ w32_symlink(UINT cp, const char *src, const char *link) from the link's directory. */ { WCHAR *sep; - int absolute = + int independent = (((wsrc[0] >= L'A' && wsrc[0] <= L'Z') || (wsrc[0] >= L'a' && wsrc[0] <= L'z')) && wsrc[1] == L':') || wsrc[0] == L'\\'; - if (!absolute && (sep = wcsrchr(wlink, L'\\')) != NULL) { + if (!independent && (sep = wcsrchr(wlink, L'\\')) != NULL) { VALUE buf2; size_t dirlen = sep - wlink + 1; size_t srclen = wcslen(wsrc) + 1; From b52a02ccc25e1779f441ccb883ecccade49c24b1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 28 Jun 2026 18:19:48 +0900 Subject: [PATCH 05/12] Fix default gem detection for nested out-of-tree builds The "# default:" marker records the source path relative to the build directory, but default_gem? re-prefixed it with srcdir, resolving a non-existent path and wrongly removing every default gemspec whenever srcdir is deeper than one level. The sibling layout used on CI (srcdir=../src) only canceled the double prefix by accident. Co-Authored-By: Claude Opus 4.8 --- tool/outdate-bundled-gems.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/outdate-bundled-gems.rb b/tool/outdate-bundled-gems.rb index b272c448c6608b..ec8762f5e0138c 100755 --- a/tool/outdate-bundled-gems.rb +++ b/tool/outdate-bundled-gems.rb @@ -101,7 +101,7 @@ def default_gem?(spec) (@defaults ||= {}).fetch(spec) do File.open(prefixed(spec)) do |f| if /^# default: (\S+) (\d+\.\d+)/ =~ f.gets("") - File.mtime(prefixed($1)) <= Time.at(Rational($2)) + File.mtime($1) <= Time.at(Rational($2)) else false end From 60ab493e5c9bb261b622b6165f066e191200b342 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Sun, 28 Jun 2026 20:34:45 +0900 Subject: [PATCH 06/12] Allow minitest failures when the diff command is unavailable minitest's assertion tests compare against unified diff output produced by the `diff` command. Where it is not on PATH (e.g. a minimal Windows environment) those tests fail spuriously, so add minitest to the default allowed failures in that case. Co-Authored-By: Claude Opus 4.8 --- tool/test-bundled-gems.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tool/test-bundled-gems.rb b/tool/test-bundled-gems.rb index b603cc09d7a530..a0fc8a46980cad 100644 --- a/tool/test-bundled-gems.rb +++ b/tool/test-bundled-gems.rb @@ -16,6 +16,15 @@ 'irb', 'csv', ] : [] + +# minitest's assertion tests compare against unified diff output produced by +# the `diff` command, so they fail spuriously when it is not available. +diff_available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir| + next false if dir.empty? + exe = File.join(dir, "diff") + File.executable?(exe) || (/mswin|mingw/ =~ RUBY_PLATFORM && File.file?("#{exe}.exe")) +end +DEFAULT_ALLOWED_FAILURES << 'minitest' unless diff_available allowed_failures = ENV['TEST_BUNDLED_GEMS_ALLOW_FAILURES'] || '' allowed_failures = allowed_failures.split(',').concat(DEFAULT_ALLOWED_FAILURES).uniq.reject(&:empty?) From 2bd880dfb7000b9a7978a0c4332299a07329a721 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 16:34:07 +0900 Subject: [PATCH 07/12] Allow rake failures on uninstalled out-of-tree builds rake's TestBacktraceSuppression#test_system_dir_suppressed expects RbConfig's rubylibprefix to be suppressed from backtraces. In an uninstalled out-of-tree build it is a POSIX "/usr"-style prefix that File.expand_path turns into a drive-prefixed path on Windows, which no longer matches rake's suppression pattern. Co-Authored-By: Claude Opus 4.8 --- tool/test-bundled-gems.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tool/test-bundled-gems.rb b/tool/test-bundled-gems.rb index a0fc8a46980cad..0f6f2566831229 100644 --- a/tool/test-bundled-gems.rb +++ b/tool/test-bundled-gems.rb @@ -25,6 +25,16 @@ File.executable?(exe) || (/mswin|mingw/ =~ RUBY_PLATFORM && File.file?("#{exe}.exe")) end DEFAULT_ALLOWED_FAILURES << 'minitest' unless diff_available + +# rake's TestBacktraceSuppression#test_system_dir_suppressed expects rake to +# suppress RbConfig's rubylibprefix from backtraces. In an uninstalled +# out-of-tree build it is a POSIX "/usr"-style prefix that File.expand_path +# turns into a drive-prefixed path on Windows, which no longer matches rake's +# suppression pattern, so the test fails. +if /mswin|mingw/ =~ RUBY_PLATFORM && RbConfig::CONFIG["rubylibprefix"] !~ /\A[a-zA-Z]:/ + DEFAULT_ALLOWED_FAILURES << 'rake' +end + allowed_failures = ENV['TEST_BUNDLED_GEMS_ALLOW_FAILURES'] || '' allowed_failures = allowed_failures.split(',').concat(DEFAULT_ALLOWED_FAILURES).uniq.reject(&:empty?) From 29357ab5c642d7076c56e448648fd6937b17cc0a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 16:34:45 +0900 Subject: [PATCH 08/12] Allow rbs failures when localhost cannot be resolved rbs's stdlib Resolv tests resolve "localhost", which raises Resolv::ResolvError on hosts where the Resolv library cannot resolve it. Co-Authored-By: Claude Opus 4.8 --- tool/test-bundled-gems.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tool/test-bundled-gems.rb b/tool/test-bundled-gems.rb index 0f6f2566831229..706f665b95de51 100644 --- a/tool/test-bundled-gems.rb +++ b/tool/test-bundled-gems.rb @@ -35,6 +35,16 @@ DEFAULT_ALLOWED_FAILURES << 'rake' end +# rbs's stdlib Resolv tests need to resolve "localhost"; allow its failures on +# hosts where the Resolv library cannot resolve it. +begin + require 'resolv' + Resolv.getaddress('localhost') +rescue LoadError +rescue Resolv::ResolvError + DEFAULT_ALLOWED_FAILURES << 'rbs' +end + allowed_failures = ENV['TEST_BUNDLED_GEMS_ALLOW_FAILURES'] || '' allowed_failures = allowed_failures.split(',').concat(DEFAULT_ALLOWED_FAILURES).uniq.reject(&:empty?) From 01d6e48234ee9ba07eac954da6c3743567f6fde3 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 18:00:38 +0900 Subject: [PATCH 09/12] Run force_activate spec script through ruby on Windows Windows has no executable bit or shebang dispatch, so `bundle exec ./script.rb` is rejected as "not executable". Invoke the script via ruby there. The spec exercises force_activate under a bundle environment, not shebang execution. Co-Authored-By: Claude Opus 4.8 --- spec/bundled_gems_spec.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/bundled_gems_spec.rb b/spec/bundled_gems_spec.rb index 45ababa9ed6588..dc0e7dde6c7bfc 100644 --- a/spec/bundled_gems_spec.rb +++ b/spec/bundled_gems_spec.rb @@ -388,6 +388,12 @@ def my end context "with bundle environment" do + # Windows has no executable bit or shebang dispatch, so running the + # script directly is rejected by bundler as "not executable". Invoke it + # through ruby there. What matters here is force_activate's behavior under + # the bundle environment, not shebang execution (covered by another spec). + let(:exec_command) { Gem.win_platform? ? "exec ruby ./script.rb" : "exec ./script.rb" } + before do code = <<-RUBY #!/usr/bin/env ruby @@ -400,13 +406,13 @@ def my it "lockfile is available" do bundle "install" - bundle "exec ./script.rb" + bundle exec_command expect(err).to include("gem install csv") end it "lockfile is not available" do - bundle "exec ./script.rb" + bundle exec_command expect(err).to include("gem install csv") end From 09813e489f8baca0269d0b6fa2f1caff6a5a3462 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 19:17:43 +0900 Subject: [PATCH 10/12] Don't quote the stat argument in File time subsecond specs On Windows the backtick runs through cmd.exe, which keeps the POSIX single quotes literally. coreutils stat then fails on the invalid filename and returns empty output, so Integer(nil, 10) raises ArgumentError instead of being skipped. Co-Authored-By: Claude Opus 4.8 --- spec/ruby/core/file/atime_spec.rb | 2 +- spec/ruby/core/file/ctime_spec.rb | 2 +- spec/ruby/core/file/mtime_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/ruby/core/file/atime_spec.rb b/spec/ruby/core/file/atime_spec.rb index 5c6c110eec43f3..af9393bef496b0 100644 --- a/spec/ruby/core/file/atime_spec.rb +++ b/spec/ruby/core/file/atime_spec.rb @@ -19,7 +19,7 @@ unless ENV.key?('TRAVIS') # https://bugs.ruby-lang.org/issues/17926 ## NOTE also that some Linux systems disable atime (e.g. via mount params) for better filesystem speed. it "returns the last access time for the named file with microseconds" do - supports_subseconds = Integer(`stat -c%x '#{__FILE__}'`[/\.(\d{1,6})/, 1], 10) + supports_subseconds = Integer(`stat -c%x #{__FILE__}`[/\.(\d{1,6})/, 1], 10) if supports_subseconds != 0 expected_time = Time.at(Time.now.to_i + 0.123456) File.utime expected_time, 0, @file diff --git a/spec/ruby/core/file/ctime_spec.rb b/spec/ruby/core/file/ctime_spec.rb index cf37d1f4eeca96..25058fe6820a24 100644 --- a/spec/ruby/core/file/ctime_spec.rb +++ b/spec/ruby/core/file/ctime_spec.rb @@ -16,7 +16,7 @@ platform_is :linux, :windows do it "returns the change time for the named file (the time at which directory information about the file was changed, not the file itself) with microseconds." do - supports_subseconds = Integer(`stat -c%z '#{__FILE__}'`[/\.(\d{1,6})/, 1], 10) + supports_subseconds = Integer(`stat -c%z #{__FILE__}`[/\.(\d{1,6})/, 1], 10) if supports_subseconds != 0 File.ctime(__FILE__).usec.should > 0 else diff --git a/spec/ruby/core/file/mtime_spec.rb b/spec/ruby/core/file/mtime_spec.rb index d83725e25d9ecc..2e28695d977ab1 100644 --- a/spec/ruby/core/file/mtime_spec.rb +++ b/spec/ruby/core/file/mtime_spec.rb @@ -18,7 +18,7 @@ platform_is :linux, :windows do unless ENV.key?('TRAVIS') # https://bugs.ruby-lang.org/issues/17926 it "returns the modification Time of the file with microseconds" do - supports_subseconds = Integer(`stat -c%y '#{__FILE__}'`[/\.(\d{1,6})/, 1], 10) + supports_subseconds = Integer(`stat -c%y #{__FILE__}`[/\.(\d{1,6})/, 1], 10) if supports_subseconds != 0 expected_time = Time.at(Time.now.to_i + 0.123456) File.utime 0, expected_time, @filename From d3d4d3379ac473268a47299fea431766b033499a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 20:05:12 +0900 Subject: [PATCH 11/12] Omit test_realpath_mount_point without mountvol privilege mountvol needs elevation to (un)mount, and without it prints "Access is denied." to stdout. Under the parallel runner the worker stdout is the IPC channel, so the message leaked through as a bogus "unknown command". Capture the output and omit like test_readlink_junction. Co-Authored-By: Claude Opus 4.8 --- test/ruby/test_file_exhaustive.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/ruby/test_file_exhaustive.rb b/test/ruby/test_file_exhaustive.rb index 77e4ab1a6277dd..0040e8d9f00a5e 100644 --- a/test/ruby/test_file_exhaustive.rb +++ b/test/ruby/test_file_exhaustive.rb @@ -827,10 +827,11 @@ def test_readlink_junction def test_realpath_mount_point vol = IO.popen(["mountvol", DRIVE, "/l"], &:read).strip Dir.mkdir(mnt = File.join(@dir, mntpnt = "mntpnt")) - system("mountvol", mntpnt, vol, chdir: @dir) + err = IO.popen(%W"mountvol #{mntpnt} #{vol}", chdir: @dir, err: %i[child out], &:read) + omit err unless $?.success? assert_equal(mnt, File.realpath(mnt)) ensure - system("mountvol", mntpnt, "/d", chdir: @dir) + system("mountvol", mntpnt, "/d", chdir: @dir, out: IO::NULL, err: IO::NULL) end end From 4ef40e52b2b3f7d18634ff5476e5914d1e16e8b1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 29 Jun 2026 20:11:37 +0900 Subject: [PATCH 12/12] Account for Windows console stdin encoding in test_stdin On Windows default_external is UTF-8, but an interactive console STDIN is read in the locale (console code page) encoding and transcoded to the default external encoding. The previous assertion held only when stdin was redirected, so it failed on a console with a non-UTF-8 code page. Expect the locale encoding when stdin is a tty. Co-Authored-By: Claude Opus 4.8 --- test/ruby/test_io_m17n.rb | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/ruby/test_io_m17n.rb b/test/ruby/test_io_m17n.rb index 83d4fb0c7b525e..1736d01f78e3fd 100644 --- a/test/ruby/test_io_m17n.rb +++ b/test/ruby/test_io_m17n.rb @@ -404,8 +404,17 @@ def test_dup_undef end def test_stdin - assert_equal(Encoding.default_external, STDIN.external_encoding) - assert_equal(nil, STDIN.internal_encoding) + encoding = Encoding.default_external + internal = nil + if /mswin|mingw/ =~ RUBY_PLATFORM and STDIN.tty? + # Interactive console input on Windows is read in the locale (console + # code page) encoding and transcoded to the default external encoding. + encoding = Encoding.find("locale") + internal = Encoding.default_internal || Encoding.default_external + internal = nil if internal == encoding + end + assert_equal(encoding, STDIN.external_encoding) + assert_equal(internal, STDIN.internal_encoding) end def test_stdout