From 487336b4dbed465940204e99e87d7be3d02ecc04 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 c433cf1a7798..feff22f934f4 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -470,7 +470,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 @@ -725,6 +725,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 2dea8e377f0a87474c6e6b44517564f06b6a2996 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 7c1693410178..89ade13e40ad 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 759dedc037d8..cb72d53e52e5 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1260,6 +1260,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 2a653dab97d2..a583b8874f05 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 8bb5a2f78042be051df20e1b6c0e03a06738301f 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 | 13 ++++++++++++- bin/windows_run_as_user | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 bin/windows_run_as_user diff --git a/.github/workflows/rubygems.yml b/.github/workflows/rubygems.yml index 89ade13e40ad..9f36ad0db4d3 100644 --- a/.github/workflows/rubygems.yml +++ b/.github/workflows/rubygems.yml @@ -41,10 +41,16 @@ jobs: os: { name: Ubuntu, value: ubuntu-24.04 } use_psych: true + - ruby: { name: no symlinks, value: 4.0.0 } + os: { name: Windows, value: windows-2025 } + symlink: off env: RUBYGEMS_USE_PSYCH: ${{ matrix.use_psych || 'false' }} 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 @@ -55,9 +61,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 1a1e2f3ac4636df499df9d10af1a67dea14b1c5c 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 9f36ad0db4d3..4ccc8f1af04e 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 }} @@ -61,11 +63,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' @@ -78,6 +75,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 feff22f934f4..235ccdb458f1 100644 --- a/lib/rubygems/package.rb +++ b/lib/rubygems/package.rb @@ -470,7 +470,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 @@ -726,14 +726,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 cb72d53e52e5..b27427306956 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1268,10 +1268,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 a583b8874f05..43d4a07bd496 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 32269576759eef0e6f600699d541e5f75020b3fd 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 475081e4aba71f57db84f820133b27b48f725e60 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|