Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions lib/memfs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,78 @@ 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/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
# rubocop:disable Metrics/PerceivedComplexity
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}:" }

# 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?(%r{\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
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/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
# rubocop:enable Metrics/PerceivedComplexity

require 'memfs/file_system'
require 'memfs/dir'
require 'memfs/file'
Expand Down
27 changes: 20 additions & 7 deletions lib/memfs/dir.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,25 +63,35 @@ def self.getwd
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
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

# 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?(original_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
# rubocop:enable Metrics/PerceivedComplexity

def self.home(*args)
original_dir_class.home(*args)
Expand All @@ -108,17 +118,20 @@ def self.rmdir(path)
end

def self.tmpdir
'/tmp'
File.join(MemFs.platform_root, 'tmp')
end

# 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
Expand Down
21 changes: 20 additions & 1 deletion lib/memfs/fake/directory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,20 @@ def entry_names
entries.keys
end

# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
def find(path)
path = MemFs.normalize_path(path)

# 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)
path = path[name.length..]
end

path = path.gsub(%r{(\A/+|/+\z)}, '')
return self if path.empty?

parts = path.split('/', 2)

if entry_names.include?(path)
Expand All @@ -30,6 +42,7 @@ def find(path)
entries[parts.first].find(parts.last)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity

def initialize(*args)
super
Expand All @@ -42,7 +55,7 @@ def parent=(parent)
end

def path
name == '/' ? '/' : super
root_directory? ? name : super
end

def paths
Expand All @@ -63,6 +76,12 @@ def remove_entry(entry)
def type
'directory'
end

private

def root_directory?
parent.nil? || MemFs.root_path?(name)
end
end
end
end
11 changes: 9 additions & 2 deletions lib/memfs/fake/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,25 @@ 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
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
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength

def ino
@ino ||= rand(1000)
Expand Down
11 changes: 7 additions & 4 deletions lib/memfs/file_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions spec/fileutils_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading