From 0365027a16115f3db222c7fac37650f186be073f Mon Sep 17 00:00:00 2001 From: Lars Kanis | SINC NOVATION Date: Mon, 2 Feb 2026 10:39:39 +0100 Subject: [PATCH 1/6] Fallback to copy symlinks on Windows Symlinks are not permitted for an ordinary Windows user. To use them, a switch called "Development Mode" in the system settings has to be enabled. This prevents users per default to install gems using symlinks. One such example is haml-rails-3.0.0. It uses symlinks for files and directories. The resulting error message is not very helpful: ``` ERROR: While executing gem ... (Gem::FilePermissionError) You don't have write permissions for the directory. (Gem::FilePermissionError) ``` Instead of fixing the situaltion in the affected gem or to skip symlinks completely, I think the better solution would be to make copies of the files in question. This would allow Windows users to install and use the gem smoothly. --- lib/rubygems/package.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb index e6e078dce406..67c2729760cd 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -466,7 +466,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc: symlinks.each do |name, target, destination, real_destination| if File.exist?(real_destination) - File.symlink(target, destination) + create_symlink_safe(target, destination) else alert_warning "#{@spec.full_name} ships with a dangling symlink named #{name} pointing to missing #{target} file. Ignoring" end @@ -721,6 +721,16 @@ def limit_read(io, name, limit) raise Gem::Package::FormatError, "#{name} is too big (over #{limit} bytes)" if bytes.size > limit bytes end + + # Create a symlink and fallback to copy the file or directory on Windows, + # where symlink creation needs special privileges in form of the Developer Mode. + def create_symlink_safe(old_name, new_name) # :nodoc: + File.symlink(old_name, new_name) + rescue Errno::EACCES + raise unless Gem.win_platform? + from = File.expand_path(old_name, File.dirname(new_name)) + FileUtils.cp_r(from, new_name) + end end require_relative "package/digest_io" From c8149245e7c1c8ba7adf9ad074571e996d0c393a Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sun, 15 Feb 2026 20:06:32 +0100 Subject: [PATCH 2/6] Adjust tests reg. previous commit and symlinks on Windows This adjust symlink tests on Windows to succeed with developer mode enabled and disabled. Move `symlink_supported?` to be available for other tests. Return `true` only if symlink permission is granted (developer mode enabled). --- .github/workflows/rubygems.yml | 2 - test/rubygems/helper.rb | 18 +++++++++ test/rubygems/installer_test_case.rb | 17 --------- test/rubygems/test_gem_installer.rb | 8 +++- test/rubygems/test_gem_package.rb | 56 +++++++++++++++++----------- 5 files changed, 58 insertions(+), 43 deletions(-) diff --git a/.github/workflows/rubygems.yml b/.github/workflows/rubygems.yml index b9e30e8ea825..0bbb7c7e3c9d 100644 --- a/.github/workflows/rubygems.yml +++ b/.github/workflows/rubygems.yml @@ -4,8 +4,6 @@ on: pull_request: push: - branches: - - master concurrency: group: ci-${{ github.ref }}-${{ github.workflow }} diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index dc40f4ecb1f8..ef443a1fe3ea 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1238,6 +1238,24 @@ def nmake_found? system("nmake /? 1>NUL 2>&1") end + @@symlink_supported = nil + + # This is needed for Windows environment without symlink support enabled (the default + # for non admin) to be able to skip test for features using symlinks. + def symlink_supported? + if @@symlink_supported.nil? + begin + File.symlink(File.join(@tempdir, "a"), File.join(@tempdir, "b")) + File.unlink(File.join(@tempdir, "b")) + rescue NotImplementedError, SystemCallError + @@symlink_supported = false + else + @@symlink_supported = true + end + end + @@symlink_supported + end + # In case we're building docs in a background process, this method waits for # that process to exit (or if it's already been reaped, or never happened, # swallows the Errno::ECHILD error). diff --git a/test/rubygems/installer_test_case.rb b/test/rubygems/installer_test_case.rb index ded205c5f562..9e0cbf9c692b 100644 --- a/test/rubygems/installer_test_case.rb +++ b/test/rubygems/installer_test_case.rb @@ -237,21 +237,4 @@ def test_ensure_writable_dir_creates_missing_parent_directories assert_directory_exists non_existent_parent, "Parent directory should exist now" assert_directory_exists target_dir, "Target directory should exist now" end - - @@symlink_supported = nil - - # This is needed for Windows environment without symlink support enabled (the default - # for non admin) to be able to skip test for features using symlinks. - def symlink_supported? - if @@symlink_supported.nil? - begin - File.symlink("", "") - rescue Errno::ENOENT, Errno::EEXIST - @@symlink_supported = true - rescue NotImplementedError, SystemCallError - @@symlink_supported = false - end - end - @@symlink_supported - end end diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 0220a41f88a4..1eaecbdf4fdd 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -759,8 +759,12 @@ def test_generate_bin_with_dangling_symlink errors = @ui.error.split("\n") assert_equal "WARNING: ascii_binder-0.1.10.1 ships with a dangling symlink named bin/ascii_binder pointing to missing bin/asciibinder file. Ignoring", errors.shift - assert_empty errors - + if symlink_supported? + assert_empty errors + else + assert_match(/Unable to use symlinks, installing wrapper/i, + errors.to_s) + end assert_empty @ui.output end diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb index 0c214a232b76..1f6d9ad458d8 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -190,7 +190,7 @@ def test_add_files_symlink File.symlink("../lib/code.rb", "lib/code_sym2.rb") rescue Errno::EACCES => e if Gem.win_platform? - pend "symlink - must be admin with no UAC on Windows" + pend "symlink - developer mode must be enabled on Windows" else raise e end @@ -583,25 +583,45 @@ def test_extract_tar_gz_symlink_relative_path tar.add_symlink "lib/foo.rb", "../relative.rb", 0o644 end - begin - package.extract_tar_gz tgz_io, @destination - rescue Errno::EACCES => e - if Gem.win_platform? - pend "symlink - must be admin with no UAC on Windows" - else - raise e - end - end + package.extract_tar_gz tgz_io, @destination extracted = File.join @destination, "lib/foo.rb" assert_path_exist extracted - assert_equal "../relative.rb", - File.readlink(extracted) + if symlink_supported? + assert_equal "../relative.rb", + File.readlink(extracted) + end assert_equal "hi", + File.read(extracted), + "should read file content either by following symlink or on Windows by reading copy" + end + + def test_extract_tar_gz_symlink_directory + package = Gem::Package.new @gem + package.verify + + tgz_io = util_tar_gz do |tar| + tar.add_symlink "link", "lib/orig", 0o644 + tar.mkdir "lib", 0o755 + tar.mkdir "lib/orig", 0o755 + tar.add_file "lib/orig/file.rb", 0o644 do |io| + io.write "ok" + end + end + + package.extract_tar_gz tgz_io, @destination + extracted = File.join @destination, "link/file.rb" + assert_path_exist extracted + if symlink_supported? + assert_equal "lib/orig", + File.readlink(File.dirname(extracted)) + end + assert_equal "ok", File.read(extracted) end def test_extract_symlink_into_symlink_dir + pend "Symlinks not supported or not enabled" unless symlink_supported? package = Gem::Package.new @gem tgz_io = util_tar_gz do |tar| tar.mkdir "lib", 0o755 @@ -665,14 +685,10 @@ def test_extract_symlink_parent destination_subdir = File.join @destination, "subdir" FileUtils.mkdir_p destination_subdir - expected_exceptions = Gem.win_platform? ? [Gem::Package::SymlinkError, Errno::EACCES] : [Gem::Package::SymlinkError] - - e = assert_raise(*expected_exceptions) do + e = assert_raise(Gem::Package::SymlinkError) do package.extract_tar_gz tgz_io, destination_subdir end - pend "symlink - must be admin with no UAC on Windows" if Errno::EACCES === e - assert_equal("installing symlink 'lib/link' pointing to parent path #{@destination} of " \ "#{destination_subdir} is not allowed", e.message) @@ -700,14 +716,10 @@ def test_extract_symlink_parent_doesnt_delete_user_dir tar.add_symlink "link/dir", ".", 16_877 end - expected_exceptions = Gem.win_platform? ? [Gem::Package::SymlinkError, Errno::EACCES] : [Gem::Package::SymlinkError] - - e = assert_raise(*expected_exceptions) do + e = assert_raise(Gem::Package::SymlinkError) do package.extract_tar_gz tgz_io, destination_subdir end - pend "symlink - must be admin with no UAC on Windows" if Errno::EACCES === e - assert_equal("installing symlink 'link' pointing to parent path #{destination_user_dir} of " \ "#{destination_subdir} is not allowed", e.message) From 1c8bc3963b1d35fb8d43cfc7c02d934da491f993 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sun, 15 Feb 2026 20:45:06 +0100 Subject: [PATCH 3/6] Add a test run on Windows with non-admin user and disabled developer mode This way we can ensure that rubygems runs on a normal user account with symlinks disabled. That is the default on an interactive Windows. --- .github/workflows/rubygems.yml | 14 +++++++++++++- bin/windows_run_as_user | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 bin/windows_run_as_user diff --git a/.github/workflows/rubygems.yml b/.github/workflows/rubygems.yml index 0bbb7c7e3c9d..15f724e63973 100644 --- a/.github/workflows/rubygems.yml +++ b/.github/workflows/rubygems.yml @@ -37,7 +37,14 @@ jobs: - ruby: { name: truffleruby, value: truffleruby-24.2.1 } os: { name: Ubuntu, value: ubuntu-24.04 } + - ruby: { name: no symlinks, value: 4.0.0 } + os: { name: Windows, value: windows-2025 } + symlink: off + steps: + - name: disable development mode on Windows + run: powershell -c "Set-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock -Name AllowDevelopmentWithoutDevLicense -Value 0" + if: matrix.symlink == 'off' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -48,9 +55,14 @@ jobs: bundler: none - name: Install Dependencies run: bin/rake setup + - name: Run Test with non-Admin user + run: | + gem inst win32-process --no-doc --conservative + ruby bin/windows_run_as_user ruby -S rake test + if: matrix.symlink == 'off' - name: Run Test run: bin/rake test - if: matrix.ruby.name != 'truffleruby' && matrix.ruby.name != 'jruby' + if: matrix.ruby.name != 'truffleruby' && matrix.ruby.name != 'jruby' && matrix.symlink != 'off' - name: Run Test isolatedly run: bin/rake test:isolated if: matrix.ruby.name == '3.4' && matrix.os.name != 'Windows' diff --git a/bin/windows_run_as_user b/bin/windows_run_as_user new file mode 100644 index 000000000000..47708a104ece --- /dev/null +++ b/bin/windows_run_as_user @@ -0,0 +1,30 @@ +require "win32/process" +require "rbconfig" + +TESTUSER = "testuser" + +system("net user #{TESTUSER} /del 2>NUL") +system("net user #{TESTUSER} \"Password123+\" /add") || raise +system("icacls . /grant #{TESTUSER}:(OI)(CI)(IO)(F)") + +stdout_read, stdout_write = IO.pipe +cmd = ARGV.join(" ") +env = { + "TMP" => "#{Dir.pwd}/tmp", + "TEMP" => "#{Dir.pwd}/tmp" +} +pinfo = Process.create command_line: cmd, + with_logon: TESTUSER, + password: "Password123+", + cwd: Dir.pwd, + environment: ENV.to_h.merge(env).map{|k,v| "#{k}=#{v}" }, + startup_info: { stdout: stdout_write, stderr: stdout_write } + +stdout_write.close +out = stdout_read.read +puts out + +# Wait for process to terminate +sleep 0.1 while !(ecode=Process.get_exitcode(pinfo.process_id)) + +exit ecode From 180c269a5c121348fe3ec6ed0d050c586c85da31 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Fri, 6 Mar 2026 09:22:13 +0100 Subject: [PATCH 4/6] Update Windows symlink patch based on PR reviews - Move non-admin test down to correct section - Add comments about non-admin user creation - Use block version of IO.pipe - Use variables for user name and password - Move cleanup per File.unlink out of tested+rescued block - Omit test completely instead of error prone handling in rescue branch - Use a dedicated method on Windows to create symlinks --- .github/workflows/rubygems.yml | 12 ++++++---- bin/windows_run_as_user | 36 +++++++++++++++++------------ lib/rubygems/package.rb | 23 ++++++++++-------- test/rubygems/helper.rb | 2 +- test/rubygems/test_gem_installer.rb | 2 +- test/rubygems/test_gem_package.rb | 17 +++++--------- 6 files changed, 50 insertions(+), 42 deletions(-) diff --git a/.github/workflows/rubygems.yml b/.github/workflows/rubygems.yml index 15f724e63973..83cbc5a880d9 100644 --- a/.github/workflows/rubygems.yml +++ b/.github/workflows/rubygems.yml @@ -4,6 +4,8 @@ on: pull_request: push: + branches: + - master concurrency: group: ci-${{ github.ref }}-${{ github.workflow }} @@ -55,11 +57,6 @@ jobs: bundler: none - name: Install Dependencies run: bin/rake setup - - name: Run Test with non-Admin user - run: | - gem inst win32-process --no-doc --conservative - ruby bin/windows_run_as_user ruby -S rake test - if: matrix.symlink == 'off' - name: Run Test run: bin/rake test if: matrix.ruby.name != 'truffleruby' && matrix.ruby.name != 'jruby' && matrix.symlink != 'off' @@ -72,6 +69,11 @@ jobs: - name: Run Test (Truffleruby) run: TRUFFLERUBYOPT="--experimental-options --testing-rubygems" bin/rake test if: matrix.ruby.name == 'truffleruby' + - name: Run Test with non-Admin user + run: | + gem inst win32-process --no-doc --conservative + ruby bin/windows_run_as_user ruby -S rake test + if: matrix.symlink == 'off' timeout-minutes: 60 diff --git a/bin/windows_run_as_user b/bin/windows_run_as_user index 47708a104ece..df098b5f4961 100644 --- a/bin/windows_run_as_user +++ b/bin/windows_run_as_user @@ -1,30 +1,36 @@ require "win32/process" require "rbconfig" -TESTUSER = "testuser" +testuser = "testuser" +testpassword = "Password123+" -system("net user #{TESTUSER} /del 2>NUL") -system("net user #{TESTUSER} \"Password123+\" /add") || raise -system("icacls . /grant #{TESTUSER}:(OI)(CI)(IO)(F)") +# Remove a previous test user if present +system("net user #{testuser} /del 2>NUL") +# Create a new non-admin user +system("net user #{testuser} \"#{testpassword}\" /add") +# Give the new user full access permission on the working directory +system("icacls . /grant #{testuser}:(OI)(CI)(IO)F") -stdout_read, stdout_write = IO.pipe -cmd = ARGV.join(" ") -env = { +pinfo = nil +IO.pipe do |stdout_read, stdout_write| + cmd = ARGV.join(" ") + env = { "TMP" => "#{Dir.pwd}/tmp", "TEMP" => "#{Dir.pwd}/tmp" -} -pinfo = Process.create command_line: cmd, - with_logon: TESTUSER, - password: "Password123+", + } + pinfo = Process.create command_line: cmd, + with_logon: testuser, + password: testpassword, cwd: Dir.pwd, environment: ENV.to_h.merge(env).map{|k,v| "#{k}=#{v}" }, startup_info: { stdout: stdout_write, stderr: stdout_write } -stdout_write.close -out = stdout_read.read -puts out + stdout_write.close + out = stdout_read.read + puts out +end # Wait for process to terminate -sleep 0.1 while !(ecode=Process.get_exitcode(pinfo.process_id)) +sleep 1 while !(ecode=Process.get_exitcode(pinfo.process_id)) exit ecode diff --git a/lib/rubygems/package.rb b/lib/rubygems/package.rb index 67c2729760cd..531688e7bd89 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -466,7 +466,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc: symlinks.each do |name, target, destination, real_destination| if File.exist?(real_destination) - create_symlink_safe(target, destination) + create_symlink(target, destination) else alert_warning "#{@spec.full_name} ships with a dangling symlink named #{name} pointing to missing #{target} file. Ignoring" end @@ -722,14 +722,19 @@ def limit_read(io, name, limit) bytes end - # Create a symlink and fallback to copy the file or directory on Windows, - # where symlink creation needs special privileges in form of the Developer Mode. - def create_symlink_safe(old_name, new_name) # :nodoc: - File.symlink(old_name, new_name) - rescue Errno::EACCES - raise unless Gem.win_platform? - from = File.expand_path(old_name, File.dirname(new_name)) - FileUtils.cp_r(from, new_name) + if Gem.win_platform? + # Create a symlink and fallback to copy the file or directory on Windows, + # where symlink creation needs special privileges in form of the Developer Mode. + def create_symlink(old_name, new_name) + File.symlink(old_name, new_name) + rescue Errno::EACCES + from = File.expand_path(old_name, File.dirname(new_name)) + FileUtils.cp_r(from, new_name) + end + else + def create_symlink(old_name, new_name) + File.symlink(old_name, new_name) + end end end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index ef443a1fe3ea..12a20340375c 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1246,10 +1246,10 @@ def symlink_supported? if @@symlink_supported.nil? begin File.symlink(File.join(@tempdir, "a"), File.join(@tempdir, "b")) - File.unlink(File.join(@tempdir, "b")) rescue NotImplementedError, SystemCallError @@symlink_supported = false else + File.unlink(File.join(@tempdir, "b")) @@symlink_supported = true end end diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 1eaecbdf4fdd..f20771c5f02e 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -763,7 +763,7 @@ def test_generate_bin_with_dangling_symlink assert_empty errors else assert_match(/Unable to use symlinks, installing wrapper/i, - errors.to_s) + errors.to_s) end assert_empty @ui.output end diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb index 1f6d9ad458d8..31c2ff379883 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -175,6 +175,9 @@ def test_add_files end def test_add_files_symlink + unless symlink_supported? + omit("symlink - developer mode must be enabled on Windows") + end spec = Gem::Specification.new spec.files = %w[lib/code.rb lib/code_sym.rb lib/code_sym2.rb] @@ -185,16 +188,8 @@ def test_add_files_symlink end # NOTE: 'code.rb' is correct, because it's relative to lib/code_sym.rb - begin - File.symlink("code.rb", "lib/code_sym.rb") - File.symlink("../lib/code.rb", "lib/code_sym2.rb") - rescue Errno::EACCES => e - if Gem.win_platform? - pend "symlink - developer mode must be enabled on Windows" - else - raise e - end - end + File.symlink("code.rb", "lib/code_sym.rb") + File.symlink("../lib/code.rb", "lib/code_sym2.rb") package = Gem::Package.new "bogus.gem" package.spec = spec @@ -621,7 +616,7 @@ def test_extract_tar_gz_symlink_directory end def test_extract_symlink_into_symlink_dir - pend "Symlinks not supported or not enabled" unless symlink_supported? + omit "Symlinks not supported or not enabled" unless symlink_supported? package = Gem::Package.new @gem tgz_io = util_tar_gz do |tar| tar.mkdir "lib", 0o755 From de64429f17c3c43a7c26ab94a0c795b50e299b39 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sat, 7 Mar 2026 17:25:00 +0100 Subject: [PATCH 5/6] Print outputs of non-admin Windows user as soon as possible --- bin/windows_run_as_user | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/windows_run_as_user b/bin/windows_run_as_user index df098b5f4961..7f8c265d59bd 100644 --- a/bin/windows_run_as_user +++ b/bin/windows_run_as_user @@ -5,6 +5,7 @@ testuser = "testuser" testpassword = "Password123+" # Remove a previous test user if present +# See https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/net-user system("net user #{testuser} /del 2>NUL") # Create a new non-admin user system("net user #{testuser} \"#{testpassword}\" /add") @@ -26,8 +27,9 @@ IO.pipe do |stdout_read, stdout_write| startup_info: { stdout: stdout_write, stderr: stdout_write } stdout_write.close - out = stdout_read.read - puts out + stdout_read.each_line do |line| + puts(line) + end end # Wait for process to terminate From 7df9a0c9cfbec6b7556a2463f875486d4a3a23fc Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Sun, 8 Mar 2026 20:41:16 +0100 Subject: [PATCH 6/6] Remove permission change in rubygems tests for unprivileged user It is not necessary on github actions. --- bin/windows_run_as_user | 2 -- 1 file changed, 2 deletions(-) diff --git a/bin/windows_run_as_user b/bin/windows_run_as_user index 7f8c265d59bd..358e91f680de 100644 --- a/bin/windows_run_as_user +++ b/bin/windows_run_as_user @@ -9,8 +9,6 @@ testpassword = "Password123+" system("net user #{testuser} /del 2>NUL") # Create a new non-admin user system("net user #{testuser} \"#{testpassword}\" /add") -# Give the new user full access permission on the working directory -system("icacls . /grant #{testuser}:(OI)(CI)(IO)F") pinfo = nil IO.pipe do |stdout_read, stdout_write|