From 782451284244a99ef75e8bdec32ada4a4cd7b5e0 Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 3 Feb 2026 13:06:02 +0100 Subject: [PATCH 1/6] Actually adding Windows support --- lib/memfs.rb | 61 ++++++++++++++++++++++++++++++++++++ lib/memfs/dir.rb | 12 +++++-- lib/memfs/fake/directory.rb | 17 +++++++++- lib/memfs/file_system.rb | 11 ++++--- spec/memfs/dir_spec.rb | 8 +++-- spec/memfs/file/stat_spec.rb | 4 +-- spec/memfs/file_spec.rb | 44 +++++++++++++------------- spec/spec_helper.rb | 11 +++++++ 8 files changed, 133 insertions(+), 35 deletions(-) diff --git a/lib/memfs.rb b/lib/memfs.rb index 46c6da0..44e605f 100644 --- a/lib/memfs.rb +++ b/lib/memfs.rb @@ -35,6 +35,67 @@ def self.windows? /mswin|bccwin|mingw/ =~ RUBY_PLATFORM end + # Returns the platform-specific root path (e.g., '/' on Unix, 'D:/' on Windows) + def self.platform_root + @platform_root || default_platform_root + end + + # Allows setting a custom platform root (mainly for testing) + def self.platform_root=(value) + @platform_root = value + end + + # Resets platform_root to the default value + def self.reset_platform_root! + @platform_root = nil + end + + # Returns the default platform root based on the current OS + def self.default_platform_root + if windows? + # Normalize drive letter to uppercase + OriginalFile.expand_path('/').sub(/\A([a-z]):/) { "#{::Regexp.last_match(1).upcase}:" } + else + '/' + end + end + + # Check if a path is the root path (handles both '/' and 'D:/') + def self.root_path?(path) + return false if path.nil? + + normalized = normalize_path(path) + normalized == platform_root || normalized == '/' + end + + # Normalize path for consistent handling + # rubocop:disable Metrics/MethodLength + def self.normalize_path(path) + return path unless path.is_a?(String) + + # Reject UNC paths + fail ArgumentError, "UNC paths are not supported: #{path}" if path.start_with?('\\\\', '//') + + # Convert backslashes to forward slashes + path = path.tr('\\', '/') + + return path unless windows? + + # Normalize drive letter to uppercase + path = path.sub(/\A([a-z]):/) { "#{::Regexp.last_match(1).upcase}:" } + + # Convert bare '/' to platform root on Windows + if path == '/' + platform_root + elsif path.start_with?('/') && !path.match?(%r{\A[A-Z]:/}) + # Convert '/foo' to 'D:/foo' on Windows + "#{platform_root}#{path[1..]}" + else + path + end + end + # rubocop:enable Metrics/MethodLength + require 'memfs/file_system' require 'memfs/dir' require 'memfs/file' diff --git a/lib/memfs/dir.rb b/lib/memfs/dir.rb index ed72ed7..5cb7608 100644 --- a/lib/memfs/dir.rb +++ b/lib/memfs/dir.rb @@ -63,6 +63,7 @@ def self.getwd class << self; alias pwd getwd; end # rubocop:disable Lint/UnderscorePrefixedVariableName, Lint/UnusedMethodArgument + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def self.glob(patterns, _flags = 0, flags: _flags, base: nil, sort: true, &block) # rubocop:enable Lint/UnderscorePrefixedVariableName, Lint/UnusedMethodArgument patterns = [*patterns].map(&:to_s) @@ -74,14 +75,19 @@ def self.glob(patterns, _flags = 0, flags: _flags, base: nil, sort: true, &block # Special case for /* and / # A scenario where /* is not the only pattern and / should be returned is - # considered an edge-case. - list.delete('/') if patterns.first == '/*' + # considered an edge-case (platform-aware root handling). + root_pattern = MemFs.windows? ? "#{MemFs.platform_root}*" : '/*' + if ['/*', root_pattern].include?(patterns.first) + list.delete(MemFs.platform_root) + list.delete('/') # Also handle Unix-style if passed + end return list unless block_given? list.each(&block) nil end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def self.home(*args) original_dir_class.home(*args) @@ -108,7 +114,7 @@ def self.rmdir(path) end def self.tmpdir - '/tmp' + File.join(MemFs.platform_root, 'tmp') end # rubocop:disable Metrics/MethodLength diff --git a/lib/memfs/fake/directory.rb b/lib/memfs/fake/directory.rb index 8861b3e..2b2685f 100644 --- a/lib/memfs/fake/directory.rb +++ b/lib/memfs/fake/directory.rb @@ -20,8 +20,16 @@ def entry_names entries.keys end + # rubocop:disable Metrics/AbcSize def find(path) + path = MemFs.normalize_path(path) + + # Strip root prefix if present + path = path[name.length..] if root_directory? && path.start_with?(name) + path = path.gsub(%r{(\A/+|/+\z)}, '') + return self if path.empty? + parts = path.split('/', 2) if entry_names.include?(path) @@ -30,6 +38,7 @@ def find(path) entries[parts.first].find(parts.last) end end + # rubocop:enable Metrics/AbcSize def initialize(*args) super @@ -42,7 +51,7 @@ def parent=(parent) end def path - name == '/' ? '/' : super + root_directory? ? name : super end def paths @@ -63,6 +72,12 @@ def remove_entry(entry) def type 'directory' end + + private + + def root_directory? + parent.nil? || MemFs.root_path?(name) + end end end end diff --git a/lib/memfs/file_system.rb b/lib/memfs/file_system.rb index 6c7b5cc..74bb98a 100644 --- a/lib/memfs/file_system.rb +++ b/lib/memfs/file_system.rb @@ -29,9 +29,10 @@ def chdir(path) end def clear! - self.root = Fake::Directory.new('/') - mkdir '/tmp' - chdir '/' + MemFs.reset_platform_root! + self.root = Fake::Directory.new(MemFs.platform_root) + mkdir File.join(MemFs.platform_root, 'tmp') + chdir MemFs.platform_root end def chmod(mode_int, file_name) @@ -53,7 +54,9 @@ def entries(path) end def find(path) - if path == '/' + path = MemFs.normalize_path(path) + + if MemFs.root_path?(path) root elsif dirname(path) == '.' working_directory.find(path) diff --git a/spec/memfs/dir_spec.rb b/spec/memfs/dir_spec.rb index c74464f..992be98 100644 --- a/spec/memfs/dir_spec.rb +++ b/spec/memfs/dir_spec.rb @@ -298,13 +298,15 @@ module MemFs describe '.home' do it 'returns the home directory of the current user' do - expect(described_class.home).to eq ENV['HOME'] + # Dir.home uses forward slashes; ENV['HOME'] may have backslashes on Windows + expect(described_class.home).to eq ENV['HOME'].tr('\\', '/') end context 'when a username is given' do it 'returns the home directory of the given user' do - home_dir = described_class.home(ENV['USER']) - expect(home_dir).to eq ENV['HOME'] + username = ENV['USER'] || ENV['USERNAME'] + home_dir = described_class.home(username) + expect(home_dir).to eq ENV['HOME'].tr('\\', '/') end end end diff --git a/spec/memfs/file/stat_spec.rb b/spec/memfs/file/stat_spec.rb index bb50c4a..604c9a3 100644 --- a/spec/memfs/file/stat_spec.rb +++ b/spec/memfs/file/stat_spec.rb @@ -453,7 +453,7 @@ module MemFs context 'when the effective user group does not own of the file' do it 'returns false' do - _fs.chown(0, 0, '/test-file') + _fs.chown(9999, 9999, '/test-file') expect(file_stat.grpowned?).to be false end end @@ -482,7 +482,7 @@ module MemFs context 'when the effective user does not own of the file' do it 'returns false' do - _fs.chown(0, 0, '/test-file') + _fs.chown(9999, 9999, '/test-file') expect(file_stat.owned?).to be false end end diff --git a/spec/memfs/file_spec.rb b/spec/memfs/file_spec.rb index dcb81ae..371cc43 100644 --- a/spec/memfs/file_spec.rb +++ b/spec/memfs/file_spec.rb @@ -40,20 +40,20 @@ module MemFs it 'converts a pathname to an absolute pathname' do path = described_class.absolute_path('./test-file') - expect(path).to eq '/test-dir/test-file' + expect(path).to eq expected_path('/test-dir/test-file') end context 'when +dir_string+ is given' do it 'uses it as the starting point' do path = described_class.absolute_path('./test-file', '/no-dir') - expect(path).to eq '/no-dir/test-file' + expect(path).to eq expected_path('/no-dir/test-file') end end context "when the given pathname starts with a '~'" do it 'does not expanded' do path = described_class.absolute_path('~/test-file') - expect(path).to eq '/test-dir/~/test-file' + expect(path).to eq expected_path('/test-dir/~/test-file') end end end @@ -490,20 +490,20 @@ module MemFs _fs.chdir '/' expanded_path = described_class.expand_path('test-file') - expect(expanded_path).to eq '/test-file' + expect(expanded_path).to eq expected_path('/test-file') end it 'references path from the current working directory' do _fs.chdir '/test-dir' expanded_path = described_class.expand_path('test-file') - expect(expanded_path).to eq '/test-dir/test-file' + expect(expanded_path).to eq expected_path('/test-dir/test-file') end context 'when +dir_string+ is provided' do it 'uses +dir_string+ as the stating point' do expanded_path = described_class.expand_path('test-file', '/test') - expect(expanded_path).to eq '/test/test-file' + expect(expanded_path).to eq expected_path('/test/test-file') end end end @@ -662,7 +662,7 @@ module MemFs context 'and the effective user group does not own of the file' do it 'returns false' do - described_class.chown 0, 0, '/test-file' + described_class.chown 9999, 9999, '/test-file' grpowned = File.grpowned?('/test-file') expect(grpowned).to be false @@ -1013,7 +1013,7 @@ module MemFs context 'and the effective user does not own of the file' do it 'returns false' do - described_class.chown 0, 0, '/test-file' + described_class.chown 9999, 9999, '/test-file' owned = File.owned?('/test-file') expect(owned).to be false @@ -1258,7 +1258,7 @@ module MemFs context 'when the path does not contain any symlink or useless dots' do it 'returns the path itself' do path = described_class.realdirpath('/test-file') - expect(path).to eq '/test-file' + expect(path).to eq expected_path('/test-file') end end @@ -1266,14 +1266,14 @@ module MemFs context 'and the symlink is a middle part' do it 'returns the path with the symlink dereferrenced' do path = described_class.realdirpath('/test-dir/sub-dir-link/test-file') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end context 'and the symlink is the last part' do it 'returns the path with the symlink dereferrenced' do path = described_class.realdirpath('/test-dir/sub-dir-link') - expect(path).to eq '/test-dir/sub-dir' + expect(path).to eq expected_path('/test-dir/sub-dir') end end end @@ -1281,7 +1281,7 @@ module MemFs context 'when the path contains useless dots' do it 'returns the path with the useless dots interpolated' do path = described_class.realdirpath('/test-dir/../test-dir/./sub-dir/test-file') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end @@ -1290,14 +1290,14 @@ module MemFs it 'uses the current working directory has base directory' do _fs.chdir '/test-dir' path = described_class.realdirpath('../test-dir/./sub-dir/test-file') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end context 'and +dir_string+ is provided' do it 'uses the given directory has base directory' do path = described_class.realdirpath('../test-dir/./sub-dir/test-file', '/test-dir') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end end @@ -1310,7 +1310,7 @@ module MemFs it 'uses the name of the target in the resulting path' do path = described_class.realdirpath('/test-dir/sub-dir/test-link') - expect(path).to eq '/test-dir/sub-dir/test' + expect(path).to eq expected_path('/test-dir/sub-dir/test') end end end @@ -1318,7 +1318,7 @@ module MemFs context 'when the last part of the given path does not exist' do it 'uses its name in the resulting path' do path = described_class.realdirpath('/test-dir/sub-dir/test') - expect(path).to eq '/test-dir/sub-dir/test' + expect(path).to eq expected_path('/test-dir/sub-dir/test') end end @@ -1341,7 +1341,7 @@ module MemFs context 'when the path does not contain any symlink or useless dots' do it 'returns the path itself' do path = described_class.realpath('/test-file') - expect(path).to eq '/test-file' + expect(path).to eq expected_path('/test-file') end end @@ -1349,14 +1349,14 @@ module MemFs context 'and the symlink is a middle part' do it 'returns the path with the symlink dereferrenced' do path = described_class.realpath('/test-dir/sub-dir-link/test-file') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end context 'and the symlink is the last part' do it 'returns the path with the symlink dereferrenced' do path = described_class.realpath('/test-dir/sub-dir-link') - expect(path).to eq '/test-dir/sub-dir' + expect(path).to eq expected_path('/test-dir/sub-dir') end end end @@ -1364,7 +1364,7 @@ module MemFs context 'when the path contains useless dots' do it 'returns the path with the useless dots interpolated' do path = described_class.realpath('/test-dir/../test-dir/./sub-dir/test-file') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end @@ -1374,14 +1374,14 @@ module MemFs _fs.chdir '/test-dir' path = described_class.realpath('../test-dir/./sub-dir/test-file') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end context 'and +dir_string+ is provided' do it 'uses the given directory has base directory' do path = described_class.realpath('../test-dir/./sub-dir/test-file', '/test-dir') - expect(path).to eq '/test-dir/sub-dir/test-file' + expect(path).to eq expected_path('/test-dir/sub-dir/test-file') end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 20e0d0a..6181977 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,6 +10,17 @@ def _fs MemFs::FileSystem.instance end +# Returns the platform-appropriate root path +def root_path + MemFs.platform_root +end + +# Converts Unix-style path to platform path for expectations +# expected_path('/test-file') => '/test-file' on Unix, 'D:/test-file' on Windows +def expected_path(unix_path) + MemFs.normalize_path(unix_path) +end + RSpec.configure do |config| config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true From 3b8268fe7a7183d587ef06c2558548b29728ef89 Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 3 Feb 2026 13:25:01 +0100 Subject: [PATCH 2/6] Fixing root directory name on Windows for path matching On Windows, File.basename('D:/') returns '/' which caused Directory#find to fail when stripping the root prefix from paths. Now root directories preserve their full normalized path (e.g., 'D:/') as their name, ensuring path.start_with?(name) works correctly. Co-Authored-By: Claude Opus 4.5 --- lib/memfs/fake/entry.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/memfs/fake/entry.rb b/lib/memfs/fake/entry.rb index e3ce6e8..c8febd3 100644 --- a/lib/memfs/fake/entry.rb +++ b/lib/memfs/fake/entry.rb @@ -70,7 +70,13 @@ def initialize(path = nil) self.gid = Process.egid self.mode = 0o666 - MemFs::File.umask self.mtime = time - self.name = MemFs::File.basename(path || '') + # Preserve full path for root directories (e.g., 'D:/' on Windows) + # since File.basename('D:/') returns '/' which breaks path matching + self.name = if path && MemFs.root_path?(path) + MemFs.normalize_path(path) + else + MemFs::File.basename(path || '') + end self.uid = Process.euid end From 3dacda91dd67f38af6f6a9dcb2a1ae9af44b589b Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 3 Feb 2026 13:38:57 +0100 Subject: [PATCH 3/6] Fixing Windows path handling in glob, chroot, and directory find - Normalize glob patterns before matching so '/test' matches 'D:/test' - Fix chroot to use platform_root instead of hardcoded '/' - Fix Directory#find to strip platform root for non-root directories - Update test expectations to use expected_path() for cross-platform support Co-Authored-By: Claude Opus 4.5 --- lib/memfs/dir.rb | 10 +++++---- lib/memfs/fake/directory.rb | 8 +++++-- spec/fileutils_spec.rb | 8 +++---- spec/memfs/dir_spec.rb | 35 ++++++++++++++++--------------- spec/memfs/fake/directory_spec.rb | 6 +++--- spec/memfs/fake/entry_spec.rb | 6 +++--- spec/memfs/fake/symlink_spec.rb | 2 +- spec/memfs/file_system_spec.rb | 14 ++++++------- spec/memfs_spec.rb | 2 +- 9 files changed, 49 insertions(+), 42 deletions(-) diff --git a/lib/memfs/dir.rb b/lib/memfs/dir.rb index 5cb7608..f72f86f 100644 --- a/lib/memfs/dir.rb +++ b/lib/memfs/dir.rb @@ -32,7 +32,7 @@ def self.chroot(path) fail Errno::EPERM, path unless Process.uid.zero? dir = fs.find_directory!(path) - dir.name = '/' + dir.name = MemFs.platform_root fs.root = dir 0 end @@ -66,9 +66,11 @@ class << self; alias pwd getwd; end # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def self.glob(patterns, _flags = 0, flags: _flags, base: nil, sort: true, &block) # rubocop:enable Lint/UnderscorePrefixedVariableName, Lint/UnusedMethodArgument - patterns = [*patterns].map(&:to_s) + original_patterns = [*patterns].map(&:to_s) + # Normalize patterns for platform (e.g., '/test' -> 'D:/test' on Windows) + normalized_patterns = original_patterns.map { |p| MemFs.normalize_path(p) } list = fs.paths.select do |path| - patterns.any? do |pattern| + normalized_patterns.any? do |pattern| File.fnmatch?(pattern, path, flags | GLOB_FLAGS) end end @@ -77,7 +79,7 @@ def self.glob(patterns, _flags = 0, flags: _flags, base: nil, sort: true, &block # A scenario where /* is not the only pattern and / should be returned is # considered an edge-case (platform-aware root handling). root_pattern = MemFs.windows? ? "#{MemFs.platform_root}*" : '/*' - if ['/*', root_pattern].include?(patterns.first) + if ['/*', root_pattern].include?(original_patterns.first) list.delete(MemFs.platform_root) list.delete('/') # Also handle Unix-style if passed end diff --git a/lib/memfs/fake/directory.rb b/lib/memfs/fake/directory.rb index 2b2685f..4c371e4 100644 --- a/lib/memfs/fake/directory.rb +++ b/lib/memfs/fake/directory.rb @@ -24,8 +24,12 @@ def entry_names def find(path) path = MemFs.normalize_path(path) - # Strip root prefix if present - path = path[name.length..] if root_directory? && path.start_with?(name) + # Strip root prefix if present (for root directory, use its name; for others, use platform_root) + if root_directory? && path.start_with?(name) + path = path[name.length..] + elsif !root_directory? && path.start_with?(MemFs.platform_root) + path = path[MemFs.platform_root.length..] + end path = path.gsub(%r{(\A/+|/+\z)}, '') return self if path.empty? diff --git a/spec/fileutils_spec.rb b/spec/fileutils_spec.rb index 0ff4ca7..96ed0d3 100644 --- a/spec/fileutils_spec.rb +++ b/spec/fileutils_spec.rb @@ -17,7 +17,7 @@ describe '.cd' do it 'changes the current working directory' do described_class.cd '/test' - expect(described_class.pwd).to eq('/test') + expect(described_class.pwd).to eq(expected_path('/test')) end if MemFs.ruby_version_gte?('2.6') @@ -42,7 +42,7 @@ context 'when called with a block' do it 'changes current working directory for the block execution' do described_class.cd '/test' do - expect(described_class.pwd).to eq('/test') + expect(described_class.pwd).to eq(expected_path('/test')) end end @@ -59,7 +59,7 @@ it 'changes directory to the last target of the link chain' do described_class.cd('/test-link') - expect(described_class.pwd).to eq('/test') + expect(described_class.pwd).to eq(expected_path('/test')) end it "raises an error if the last target of the link chain doesn't exist" do @@ -765,7 +765,7 @@ describe '.pwd' do it 'returns the name of the current directory' do described_class.cd '/test' - expect(described_class.pwd).to eq('/test') + expect(described_class.pwd).to eq(expected_path('/test')) end end diff --git a/spec/memfs/dir_spec.rb b/spec/memfs/dir_spec.rb index 992be98..4d6d439 100644 --- a/spec/memfs/dir_spec.rb +++ b/spec/memfs/dir_spec.rb @@ -13,13 +13,13 @@ module MemFs describe '[]' do context 'when a string is given' do it 'acts like calling glob' do - expect(described_class['/*']).to eq %w[/tmp /test] + expect(described_class['/*']).to eq [expected_path('/tmp'), expected_path('/test')] end end context 'when a list of strings is given' do it 'acts like calling glob' do - expect(described_class['/tm*', '/te*']).to eq %w[/tmp /test] + expect(described_class['/tm*', '/te*']).to eq [expected_path('/tmp'), expected_path('/test')] end end end @@ -27,7 +27,7 @@ module MemFs describe '.chdir' do it 'changes the current working directory' do described_class.chdir '/test' - expect(described_class.getwd).to eq('/test') + expect(described_class.getwd).to eq(expected_path('/test')) end it 'returns zero' do @@ -41,7 +41,7 @@ module MemFs context 'when a block is given' do it 'changes current working directory for the block' do described_class.chdir '/test' do - expect(described_class.pwd).to eq('/test') + expect(described_class.pwd).to eq(expected_path('/test')) end end @@ -250,7 +250,8 @@ module MemFs shared_examples 'returning matching filenames' do |pattern, filenames| it "with #{pattern}" do - expect(described_class.glob(pattern)).to eq filenames + expected = filenames.map { |f| expected_path(f) } + expect(described_class.glob(pattern)).to eq expected end end @@ -274,14 +275,14 @@ module MemFs context 'when a flag is given' do it 'uses it to compare filenames' do expect(described_class.glob('/TEST*', File::FNM_CASEFOLD)).to eq \ - %w[/test0 /test1 /test2] + [expected_path('/test0'), expected_path('/test1'), expected_path('/test2')] end end context 'when a block is given' do it 'calls the block with every matching filenames' do expect { |blk| described_class.glob('/test*', &blk) }.to \ - yield_successive_args('/test0', '/test1', '/test2') + yield_successive_args(expected_path('/test0'), expected_path('/test1'), expected_path('/test2')) end it 'returns nil' do @@ -291,7 +292,7 @@ module MemFs context 'when pattern is an array of patterns' do it 'returns the list of files matching any pattern' do - expect(described_class.glob(['/*0', '/*1'])).to eq %w[/test0 /test1] + expect(described_class.glob(['/*0', '/*1'])).to eq [expected_path('/test0'), expected_path('/test1')] end end end @@ -421,7 +422,7 @@ module MemFs describe '.tmpdir' do it 'returns /tmp' do - expect(described_class.tmpdir).to eq '/tmp' + expect(described_class.tmpdir).to eq expected_path('/tmp') end end @@ -439,19 +440,19 @@ module MemFs context 'when no block is given' do it 'creates a temporary directory and returns its path' do path = described_class.mktmpdir - expect(path).to start_with('/tmp/d') + expect(path).to start_with(expected_path('/tmp/d')) expect(described_class.exist?(path)).to be true end it 'accepts a prefix' do path = described_class.mktmpdir('myprefix') - expect(path).to start_with('/tmp/myprefix') + expect(path).to start_with(expected_path('/tmp/myprefix')) expect(described_class.exist?(path)).to be true end it 'accepts a prefix and suffix as an array' do path = described_class.mktmpdir(['prefix_', '_suffix']) - expect(path).to start_with('/tmp/prefix_') + expect(path).to start_with(expected_path('/tmp/prefix_')) expect(path).to end_with('_suffix') expect(described_class.exist?(path)).to be true end @@ -459,7 +460,7 @@ module MemFs it 'accepts a custom tmpdir' do described_class.mkdir('/custom_tmp') path = described_class.mktmpdir(nil, '/custom_tmp') - expect(path).to start_with('/custom_tmp/d') + expect(path).to start_with(expected_path('/custom_tmp/d')) expect(described_class.exist?(path)).to be true end end @@ -469,7 +470,7 @@ module MemFs yielded_path = nil described_class.mktmpdir do |path| yielded_path = path - expect(path).to start_with('/tmp/d') + expect(path).to start_with(expected_path('/tmp/d')) expect(described_class.exist?(path)).to be true end expect(described_class.exist?(yielded_path)).to be false @@ -491,7 +492,7 @@ module MemFs yielded_path = nil described_class.mktmpdir('test_') do |path| yielded_path = path - expect(path).to start_with('/tmp/test_') + expect(path).to start_with(expected_path('/tmp/test_')) expect(described_class.exist?(path)).to be true end expect(described_class.exist?(yielded_path)).to be false @@ -546,7 +547,7 @@ module MemFs describe '#path' do it "returns the path parameter passed to dir's constructor" do - expect(subject.path).to eq '/test' + expect(subject.path).to eq expected_path('/test') end end @@ -657,7 +658,7 @@ module MemFs describe '#to_path' do it "returns the path parameter passed to dir's constructor" do - expect(subject.to_path).to eq '/test' + expect(subject.to_path).to eq expected_path('/test') end end end diff --git a/spec/memfs/fake/directory_spec.rb b/spec/memfs/fake/directory_spec.rb index 83bdb49..8ea3123 100644 --- a/spec/memfs/fake/directory_spec.rb +++ b/spec/memfs/fake/directory_spec.rb @@ -91,16 +91,16 @@ module Fake end describe '#path' do - let(:root) { described_class.new('/') } + let(:root) { described_class.new(root_path) } it 'returns the directory path' do directory.parent = root - expect(directory.path).to eq('/test') + expect(directory.path).to eq(expected_path('/test')) end context 'when the directory is /' do it 'returns /' do - expect(root.path).to eq('/') + expect(root.path).to eq(root_path) end end end diff --git a/spec/memfs/fake/entry_spec.rb b/spec/memfs/fake/entry_spec.rb index 3eeb48b..1bc63ad 100644 --- a/spec/memfs/fake/entry_spec.rb +++ b/spec/memfs/fake/entry_spec.rb @@ -113,7 +113,7 @@ module Fake describe '#dereferenced_path' do it 'returns the entry path' do - expect(entry.dereferenced_path).to eq('/parent/test') + expect(entry.dereferenced_path).to eq(expected_path('/parent/test')) end end @@ -143,13 +143,13 @@ module Fake describe '#path' do it 'returns the complete path of the entry' do - expect(entry.path).to eq('/parent/test') + expect(entry.path).to eq(expected_path('/parent/test')) end end describe 'paths' do it 'returns an array containing the entry path' do - expect(entry.paths).to eq ['/parent/test'] + expect(entry.paths).to eq [expected_path('/parent/test')] end end diff --git a/spec/memfs/fake/symlink_spec.rb b/spec/memfs/fake/symlink_spec.rb index 146dc32..d6bdac6 100644 --- a/spec/memfs/fake/symlink_spec.rb +++ b/spec/memfs/fake/symlink_spec.rb @@ -54,7 +54,7 @@ module Fake it 'returns its target path' do _fs.touch('/test-file') symlink = described_class.new('/test-link', '/test-file') - expect(symlink.dereferenced_path).to eq('/test-file') + expect(symlink.dereferenced_path).to eq(expected_path('/test-file')) end end diff --git a/spec/memfs/file_system_spec.rb b/spec/memfs/file_system_spec.rb index 1b9191f..86979bf 100644 --- a/spec/memfs/file_system_spec.rb +++ b/spec/memfs/file_system_spec.rb @@ -11,7 +11,7 @@ module MemFs describe '#chdir' do it 'changes the current working directory' do subject.chdir '/test-dir' - expect(subject.getwd).to eq('/test-dir') + expect(subject.getwd).to eq(expected_path('/test-dir')) end it 'raises an error if directory does not exist' do @@ -29,7 +29,7 @@ module MemFs subject.chdir '/test-dir' do location = subject.getwd end - expect(location).to eq('/test-dir') + expect(location).to eq(expected_path('/test-dir')) end it 'gets back to previous directory once the block is finished' do @@ -44,7 +44,7 @@ module MemFs it 'sets current directory as the last link chain target' do subject.symlink('/test-dir', '/test-link') subject.chdir('/test-link') - expect(subject.getwd).to eq('/test-dir') + expect(subject.getwd).to eq(expected_path('/test-dir')) end end end @@ -140,7 +140,7 @@ module MemFs it 'sets the current directory to /' do subject.clear! - expect(subject.getwd).to eq('/') + expect(subject.getwd).to eq(root_path) end end @@ -256,7 +256,7 @@ module MemFs describe '#getwd' do it 'returns the current working directory' do subject.chdir '/test-dir' - expect(subject.getwd).to eq('/test-dir') + expect(subject.getwd).to eq(expected_path('/test-dir')) end end @@ -329,8 +329,8 @@ module MemFs end it 'returns the list of all the existing paths' do - expect(subject.paths).to eq \ - %w[/ /tmp /test-dir /test-dir/subdir /test-dir/subdir/file1 /test-dir/subdir/file2] + expected = %w[/ /tmp /test-dir /test-dir/subdir /test-dir/subdir/file1 /test-dir/subdir/file2] + expect(subject.paths).to eq(expected.map { |p| expected_path(p) }) end end diff --git a/spec/memfs_spec.rb b/spec/memfs_spec.rb index c8b96a8..f24c111 100644 --- a/spec/memfs_spec.rb +++ b/spec/memfs_spec.rb @@ -95,7 +95,7 @@ it 'creates the file in the in-memory filesystem' do file = Tempfile.create('memfs') expect(file).to be_a(File) - expect(file.path).to start_with('/tmp/memfs') + expect(file.path).to start_with(expected_path('/tmp/memfs')) expect(_fs.find(file.path)).not_to be_nil file.close end From 6a7acdb0d88f0c5634d2d958ef15f83ebc35c021 Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 3 Feb 2026 13:53:49 +0100 Subject: [PATCH 4/6] Fixing remaining Windows path issues in Directory#find and mktmpdir - Directory#find: strip platform_root first before checking directory name - mktmpdir: normalize the tmpdir argument and returned path Co-Authored-By: Claude Opus 4.5 --- lib/memfs/dir.rb | 5 ++++- lib/memfs/fake/directory.rb | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/memfs/dir.rb b/lib/memfs/dir.rb index f72f86f..466cc2d 100644 --- a/lib/memfs/dir.rb +++ b/lib/memfs/dir.rb @@ -121,12 +121,15 @@ def self.tmpdir # rubocop:disable Metrics/MethodLength def self.mktmpdir(prefix_suffix = nil, tmpdir = nil, **options) - tmpdir ||= self.tmpdir + tmpdir = MemFs.normalize_path(tmpdir || self.tmpdir) path = MemFs::OriginalDir::Tmpname.create( prefix_suffix || 'd', tmpdir, **options) { |p, _, _, _d| mkdir(p, 0o700) } + # Normalize the returned path for consistent handling across platforms + path = MemFs.normalize_path(path) + return path unless block_given? begin diff --git a/lib/memfs/fake/directory.rb b/lib/memfs/fake/directory.rb index 4c371e4..0df0978 100644 --- a/lib/memfs/fake/directory.rb +++ b/lib/memfs/fake/directory.rb @@ -24,11 +24,11 @@ def entry_names def find(path) path = MemFs.normalize_path(path) - # Strip root prefix if present (for root directory, use its name; for others, use platform_root) - if root_directory? && path.start_with?(name) - path = path[name.length..] - elsif !root_directory? && path.start_with?(MemFs.platform_root) + # Strip root prefix if present - check platform_root first, then directory name for root dirs + if path.start_with?(MemFs.platform_root) path = path[MemFs.platform_root.length..] + elsif root_directory? && path.start_with?(name) + path = path[name.length..] end path = path.gsub(%r{(\A/+|/+\z)}, '') From ccfeba8a1afad6c159cc0e44008b21981b037ecd Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 3 Feb 2026 14:05:12 +0100 Subject: [PATCH 5/6] Handling Windows drive-relative paths in normalize_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, paths like 'D:foo' (without slash after colon) are drive-relative paths. Ruby's File.dirname returns 'D:.' for these, which the fake filesystem couldn't handle. This fix converts: - 'D:' (bare drive) → 'D:/' - 'D:foo' or 'D:.' → 'D:/foo' or 'D:/.' This resolves failures in FileUtils.install tests on Ruby 3.3+ Windows. Co-Authored-By: Claude Opus 4.5 --- lib/memfs.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/memfs.rb b/lib/memfs.rb index 44e605f..610b1fe 100644 --- a/lib/memfs.rb +++ b/lib/memfs.rb @@ -84,6 +84,15 @@ def self.normalize_path(path) # Normalize drive letter to uppercase path = path.sub(/\A([a-z]):/) { "#{::Regexp.last_match(1).upcase}:" } + # Handle drive-relative paths like 'D:foo' or 'D:.' (no slash after colon) + # and bare drive letters like 'D:' (current directory on drive D) + # Convert to absolute paths since our fake fs doesn't support per-drive working directories + if path.match?(/\A[A-Z]:\z/) # Bare drive like 'D:' + path = "#{path}/" + elsif path.match?(/\A[A-Z]:[^\/]/) # Drive-relative like 'D:foo' or 'D:.' + path = path.sub(/\A([A-Z]):/, '\1:/') + end + # Convert bare '/' to platform root on Windows if path == '/' platform_root From d50acc89589501542e789b47b51eaf772cde7653 Mon Sep 17 00:00:00 2001 From: Simon Courtois Date: Tue, 3 Feb 2026 14:09:28 +0100 Subject: [PATCH 6/6] Fixing rubocop offenses from Windows path handling changes Co-Authored-By: Claude Opus 4.5 --- lib/memfs.rb | 8 +++++--- lib/memfs/dir.rb | 2 ++ lib/memfs/fake/directory.rb | 6 +++--- lib/memfs/fake/entry.rb | 3 ++- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/memfs.rb b/lib/memfs.rb index 610b1fe..66d53b7 100644 --- a/lib/memfs.rb +++ b/lib/memfs.rb @@ -69,7 +69,8 @@ def self.root_path?(path) end # Normalize path for consistent handling - # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:disable Metrics/PerceivedComplexity def self.normalize_path(path) return path unless path.is_a?(String) @@ -89,7 +90,7 @@ def self.normalize_path(path) # Convert to absolute paths since our fake fs doesn't support per-drive working directories if path.match?(/\A[A-Z]:\z/) # Bare drive like 'D:' path = "#{path}/" - elsif path.match?(/\A[A-Z]:[^\/]/) # Drive-relative like 'D:foo' or 'D:.' + elsif path.match?(%r{\A[A-Z]:[^/]}) # Drive-relative like 'D:foo' or 'D:.' path = path.sub(/\A([A-Z]):/, '\1:/') end @@ -103,7 +104,8 @@ def self.normalize_path(path) path end end - # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity require 'memfs/file_system' require 'memfs/dir' diff --git a/lib/memfs/dir.rb b/lib/memfs/dir.rb index 466cc2d..1ae84da 100644 --- a/lib/memfs/dir.rb +++ b/lib/memfs/dir.rb @@ -64,6 +64,7 @@ class << self; alias pwd getwd; end # rubocop:disable Lint/UnderscorePrefixedVariableName, Lint/UnusedMethodArgument # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:disable Metrics/PerceivedComplexity def self.glob(patterns, _flags = 0, flags: _flags, base: nil, sort: true, &block) # rubocop:enable Lint/UnderscorePrefixedVariableName, Lint/UnusedMethodArgument original_patterns = [*patterns].map(&:to_s) @@ -90,6 +91,7 @@ def self.glob(patterns, _flags = 0, flags: _flags, base: nil, sort: true, &block nil end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity def self.home(*args) original_dir_class.home(*args) diff --git a/lib/memfs/fake/directory.rb b/lib/memfs/fake/directory.rb index 0df0978..797e397 100644 --- a/lib/memfs/fake/directory.rb +++ b/lib/memfs/fake/directory.rb @@ -20,11 +20,11 @@ def entry_names entries.keys end - # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity def find(path) path = MemFs.normalize_path(path) - # Strip root prefix if present - check platform_root first, then directory name for root dirs + # Strip root prefix if present (platform_root first, then directory name for root dirs) if path.start_with?(MemFs.platform_root) path = path[MemFs.platform_root.length..] elsif root_directory? && path.start_with?(name) @@ -42,7 +42,7 @@ def find(path) entries[parts.first].find(parts.last) end end - # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity def initialize(*args) super diff --git a/lib/memfs/fake/entry.rb b/lib/memfs/fake/entry.rb index c8febd3..d39c7e3 100644 --- a/lib/memfs/fake/entry.rb +++ b/lib/memfs/fake/entry.rb @@ -61,9 +61,9 @@ def find(_path) fail Errno::ENOTDIR, path end + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def initialize(path = nil) time = Time.now - self.atime = time self.birthtime = time self.ctime = time @@ -79,6 +79,7 @@ def initialize(path = nil) end self.uid = Process.euid end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength def ino @ino ||= rand(1000)