├── .rspec ├── memfs.png ├── lib ├── memfs │ ├── version.rb │ ├── filesystem_access.rb │ ├── fake │ │ ├── file.rb │ │ ├── file │ │ │ └── content.rb │ │ ├── symlink.rb │ │ ├── directory.rb │ │ └── entry.rb │ ├── file_system.rb │ ├── dir.rb │ ├── file │ │ └── stat.rb │ ├── io.rb │ └── file.rb └── memfs.rb ├── Gemfile ├── .gitignore ├── Guardfile ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── LICENSE.txt ├── spec ├── spec_helper.rb ├── memfs │ ├── fake │ │ ├── file_spec.rb │ │ ├── symlink_spec.rb │ │ ├── directory_spec.rb │ │ ├── file │ │ │ └── content_spec.rb │ │ └── entry_spec.rb │ ├── file_system_spec.rb │ ├── dir_spec.rb │ └── file │ │ └── stat_spec.rb ├── memfs_spec.rb └── fileutils_spec.rb ├── Rakefile ├── memfs.gemspec ├── .rubocop.yml ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /memfs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonc/memfs/HEAD/memfs.png -------------------------------------------------------------------------------- /lib/memfs/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MemFs 4 | VERSION = '1.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in memfs.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /lib/memfs/filesystem_access.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MemFs 4 | module FilesystemAccess 5 | private 6 | 7 | def fs 8 | FileSystem.instance 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | lib/fileutils.rb 19 | spec/examples.txt 20 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: 'bundle exec rspec', all_after_pass: true, all_on_start: true do 4 | watch(%r{^spec/.+_spec\.rb$}) 5 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 6 | 7 | watch('lib/memfs/io.rb') { 'spec/memfs/file_spec.rb' } 8 | watch('spec/spec_helper.rb') { 'spec' } 9 | end 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: bundler 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | day: monday 9 | time: '00:00' 10 | target-branch: main 11 | versioning-strategy: increase-if-necessary 12 | 13 | - package-ecosystem: github-actions 14 | directory: / 15 | schedule: 16 | interval: weekly 17 | day: monday 18 | time: '00:00' 19 | target-branch: main 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | specs: 13 | name: Rubocop & Rspec 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby-version: ['3.2', '3.1', '3.0', '2.7', '2.6'] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Ruby ${{matrix.ruby-version}} 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{matrix.ruby-version}} 27 | bundler-cache: true 28 | - name: Updating RubyGems 29 | run: gem update --system 30 | - name: Install dependencies 31 | run: bundle install 32 | - name: Rubocop 33 | run: bundle exec rubocop -D 34 | - name: Rspec 35 | run: bundle exec rspec 36 | -------------------------------------------------------------------------------- /lib/memfs/fake/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'memfs/fake/entry' 4 | require 'memfs/fake/file/content' 5 | 6 | module MemFs 7 | module Fake 8 | class File < Entry 9 | attr_accessor :content 10 | 11 | def close 12 | @closed = true 13 | end 14 | 15 | def closed? 16 | @closed 17 | end 18 | 19 | def initialize(*args) 20 | super 21 | @content = Content.new 22 | @closed = false 23 | end 24 | 25 | def pos 26 | content.pos 27 | end 28 | 29 | def pos=(value) 30 | content.pos = value 31 | end 32 | 33 | def size 34 | content.size 35 | end 36 | 37 | def type 38 | return 'blockSpecial' if block_device 39 | return 'characterSpecial' if character_device 40 | 41 | 'file' 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Simon COURTOIS 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'coveralls' 4 | require 'memfs' 5 | 6 | Coveralls.wear! 7 | 8 | def _fs 9 | MemFs::FileSystem.instance 10 | end 11 | 12 | RSpec.configure do |config| 13 | config.expect_with :rspec do |expectations| 14 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 15 | end 16 | 17 | config.mock_with :rspec do |mocks| 18 | mocks.verify_partial_doubles = true 19 | end 20 | 21 | config.filter_run :focus 22 | config.run_all_when_everything_filtered = true 23 | config.example_status_persistence_file_path = 'spec/examples.txt' 24 | config.disable_monkey_patching! 25 | config.warnings = true 26 | if config.files_to_run.one? 27 | config.default_formatter = 'doc' 28 | end 29 | # config.profile_examples = 10 30 | config.order = :random 31 | Kernel.srand config.seed 32 | 33 | config.before { MemFs::FileSystem.instance.clear! } 34 | end 35 | 36 | RSpec.shared_examples 'aliased method' do |method, original_method| 37 | it "##{original_method}" do 38 | expect(subject.method(method)).to eq(subject.method(original_method)) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/memfs/fake/file/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'delegate' 4 | 5 | module MemFs 6 | module Fake 7 | class File < Entry 8 | class Content < SimpleDelegator 9 | attr_accessor :pos 10 | 11 | def close; end 12 | 13 | def initialize(obj = '') 14 | super 15 | 16 | @string = obj.to_s.dup 17 | @pos = 0 18 | 19 | __setobj__ @string 20 | end 21 | 22 | def puts(*strings) 23 | strings.each do |str| 24 | @string << str 25 | next if str.end_with?($/) 26 | @string << $/ 27 | end 28 | end 29 | 30 | def read(length = nil, buffer = +'') 31 | length ||= @string.length - @pos 32 | buffer.replace @string[@pos, length] 33 | @pos += buffer.bytesize 34 | buffer.empty? ? nil : buffer 35 | end 36 | 37 | def truncate(length) 38 | @string.replace @string[0, length] 39 | end 40 | 41 | def to_s 42 | @string 43 | end 44 | 45 | def write(string) 46 | text = string.to_s 47 | @string << text 48 | text.size 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/memfs/fake/symlink.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'memfs/filesystem_access' 4 | 5 | module MemFs 6 | module Fake 7 | class Symlink < Entry 8 | include MemFs::FilesystemAccess 9 | 10 | attr_reader :target 11 | 12 | def dereferenced 13 | @dereferenced ||= fs.find!(target).dereferenced 14 | end 15 | 16 | def dereferenced_name 17 | real_target.dereferenced_name 18 | end 19 | 20 | def dereferenced_path 21 | dereferenced.dereferenced_path 22 | end 23 | 24 | def find(path) 25 | dereferenced.find(path) 26 | rescue Errno::ENOENT 27 | nil 28 | end 29 | 30 | def initialize(path, target) 31 | super(path) 32 | @target = target 33 | end 34 | 35 | def method_missing(meth, *args, &block) 36 | if dereferenced.respond_to?(meth) 37 | dereferenced.public_send(meth, *args, &block) 38 | else 39 | super 40 | end 41 | end 42 | 43 | def respond_to_missing?(meth, include_private) 44 | dereferenced.respond_to?(meth, include_private) || super 45 | end 46 | 47 | def type 48 | 'link' 49 | end 50 | 51 | private 52 | 53 | def real_target 54 | fs.find(target) || Entry.new(target) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'memfs' 6 | 7 | RSpec::Core::RakeTask.new(:spec) 8 | 9 | task default: :spec 10 | 11 | desc 'Compares a MemFs class to the original Ruby one ' \ 12 | '(set CLASS to the compared class)' 13 | task :compare do 14 | class_name = ENV['CLASS'] || 'File' 15 | klass = Object.const_get(class_name) 16 | memfs_klass = MemFs.const_get(class_name) 17 | 18 | original_methods = (klass.methods - Object.methods).sort 19 | original_i_methods = (klass.instance_methods - Object.methods).sort 20 | implemented_methods = MemFs.activate { (memfs_klass.methods - Object.methods).sort } 21 | implemented_i_methods = MemFs.activate { (memfs_klass.instance_methods - Object.methods).sort } 22 | 23 | puts "CLASS: #{class_name}" 24 | puts 25 | puts 'MISSING CLASS METHODS' 26 | puts 27 | puts original_methods - implemented_methods 28 | puts 29 | puts 'MISSING INSTANCE METHODS' 30 | puts 31 | puts original_i_methods - implemented_i_methods 32 | puts 33 | puts 'ADDITIONAL METHODS' 34 | puts 35 | puts implemented_methods - original_methods 36 | puts 37 | puts 'ADDITIONAL INSTANCE METHODS' 38 | puts 39 | puts implemented_i_methods - original_i_methods 40 | end 41 | 42 | task :console do 43 | require 'irb' 44 | require 'irb/completion' 45 | require 'memfs' 46 | ARGV.clear 47 | IRB.start 48 | end 49 | -------------------------------------------------------------------------------- /lib/memfs/fake/directory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'memfs/fake/entry' 4 | 5 | module MemFs 6 | module Fake 7 | class Directory < Entry 8 | attr_accessor :entries 9 | 10 | def add_entry(entry) 11 | entries[entry.name] = entry 12 | entry.parent = self 13 | end 14 | 15 | def empty? 16 | (entries.keys - %w[. ..]).empty? 17 | end 18 | 19 | def entry_names 20 | entries.keys 21 | end 22 | 23 | def find(path) 24 | path = path.gsub(%r{(\A/+|/+\z)}, '') 25 | parts = path.split('/', 2) 26 | 27 | if entry_names.include?(path) 28 | entries[path] 29 | elsif entry_names.include?(parts.first) 30 | entries[parts.first].find(parts.last) 31 | end 32 | end 33 | 34 | def initialize(*args) 35 | super 36 | self.entries = { '.' => self, '..' => nil } 37 | end 38 | 39 | def parent=(parent) 40 | super 41 | entries['..'] = parent 42 | end 43 | 44 | def path 45 | name == '/' ? '/' : super 46 | end 47 | 48 | def paths 49 | [path] + 50 | entries 51 | .reject { |p| %w[. ..].include?(p) } 52 | .values 53 | .map(&:paths) 54 | .flatten 55 | end 56 | 57 | def remove_entry(entry) 58 | entries.delete(entry.name) 59 | end 60 | 61 | def type 62 | 'directory' 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /memfs.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'memfs/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'memfs' 8 | gem.version = MemFs::VERSION 9 | gem.authors = ['Simon COURTOIS'] 10 | gem.email = ['scourtois@cubyx.fr'] 11 | gem.description = 'MemFs provides a fake file system that can be used ' \ 12 | 'for tests. Strongly inspired by FakeFS.' 13 | gem.summary = "memfs-#{MemFs::VERSION}" 14 | gem.homepage = 'http://github.com/simonc/memfs' 15 | 16 | gem.license = 'MIT' 17 | 18 | gem.files = `git ls-files`.split($/) 19 | gem.executables = gem.files.grep(/^bin\//).map { |f| File.basename(f) } 20 | gem.test_files = gem.files.grep(/^(test|spec|features)\//) 21 | gem.require_paths = ['lib'] 22 | 23 | gem.add_development_dependency 'coveralls', '~> 0.6' 24 | gem.add_development_dependency 'rake', '~> 12.0' 25 | gem.add_development_dependency 'rspec', '~> 3.0' 26 | gem.add_development_dependency 'guard', '~> 2.6' 27 | gem.add_development_dependency 'guard-rspec', '~> 4.3' 28 | gem.add_development_dependency 'rb-inotify', '~> 0.8' 29 | gem.add_development_dependency 'rb-fsevent', '~> 0.9' 30 | gem.add_development_dependency 'rb-fchange', '~> 0.0' 31 | gem.add_development_dependency 'rubocop', '~> 1.44' 32 | 33 | listen_version = RUBY_VERSION >= '2.2.3' ? '~> 3.1' : '~> 3.0.7' 34 | gem.add_development_dependency 'listen', listen_version 35 | end 36 | -------------------------------------------------------------------------------- /spec/memfs/fake/file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MemFs 4 | module Fake 5 | ::RSpec.describe File do 6 | let(:file) { _fs.find!('/test-file') } 7 | 8 | before do 9 | _fs.touch('/test-file') 10 | end 11 | 12 | it 'stores the modification made on its content' do 13 | file.content << 'test' 14 | expect(_fs.find!('/test-file').content.to_s).to eq('test') 15 | end 16 | 17 | describe '#close' do 18 | it 'sets the file as closed?' do 19 | file.close 20 | expect(file).to be_closed 21 | end 22 | end 23 | 24 | describe '#content' do 25 | it 'returns the file content' do 26 | expect(file.content).not_to be_nil 27 | end 28 | 29 | context 'when the file is empty' do 30 | it 'returns an empty string container' do 31 | expect(file.content.to_s).to be_empty 32 | end 33 | end 34 | end 35 | 36 | describe '#type' do 37 | context 'when the file is a regular file' do 38 | it "returns 'file'" do 39 | expect(file.type).to eq('file') 40 | end 41 | end 42 | 43 | context 'when the file is a block device' do 44 | it "returns 'blockSpecial'" do 45 | file.block_device = true 46 | expect(file.type).to eq('blockSpecial') 47 | end 48 | end 49 | 50 | context 'when the file is a character device' do 51 | it "returns 'characterSpecial'" do 52 | file.character_device = true 53 | expect(file.type).to eq('characterSpecial') 54 | end 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - memfs.gemspec 4 | - spec/**/* 5 | - vendor/bundle/**/* 6 | NewCops: enable 7 | TargetRubyVersion: 2.7 8 | 9 | Layout/ArgumentAlignment: 10 | EnforcedStyle: with_fixed_indentation 11 | 12 | Layout/EmptyLineAfterGuardClause: 13 | Enabled: false 14 | 15 | Layout/FirstArrayElementIndentation: 16 | EnforcedStyle: consistent 17 | 18 | Layout/FirstHashElementIndentation: 19 | EnforcedStyle: consistent 20 | 21 | Layout/LineLength: 22 | Exclude: 23 | - Rakefile 24 | Max: 100 25 | 26 | Layout/MultilineMethodCallBraceLayout: 27 | EnforcedStyle: same_line 28 | 29 | Layout/MultilineMethodDefinitionBraceLayout: 30 | EnforcedStyle: same_line 31 | 32 | Layout/MultilineOperationIndentation: 33 | EnforcedStyle: indented 34 | 35 | Layout/ParameterAlignment: 36 | EnforcedStyle: with_fixed_indentation 37 | 38 | Metrics/AbcSize: 39 | Max: 18 40 | 41 | Metrics/ClassLength: 42 | Enabled: false 43 | 44 | Metrics/MethodLength: 45 | Max: 10 46 | 47 | Naming/PredicateName: 48 | ForbiddenPrefixes: 49 | - is_ 50 | 51 | Security/Open: 52 | Enabled: false 53 | 54 | Style/AccessModifierDeclarations: 55 | Enabled: false 56 | 57 | Style/AndOr: 58 | Enabled: false 59 | 60 | Style/Documentation: 61 | Enabled: false 62 | 63 | Style/DoubleNegation: 64 | Enabled: false 65 | 66 | Style/PercentLiteralDelimiters: 67 | PreferredDelimiters: 68 | '%': '{}' 69 | '%i': '[]' 70 | '%q': '{}' 71 | '%Q': '{}' 72 | '%r': '{}' 73 | '%s': '{}' 74 | '%w': '[]' 75 | '%W': '[]' 76 | '%x': '{}' 77 | 78 | Style/RegexpLiteral: 79 | EnforcedStyle: mixed 80 | 81 | Style/SignalException: 82 | EnforcedStyle: semantic 83 | 84 | Style/SpecialGlobalVars: 85 | Enabled: false 86 | 87 | Style/StringLiterals: 88 | EnforcedStyle: single_quotes 89 | 90 | Style/StringLiteralsInInterpolation: 91 | EnforcedStyle: single_quotes 92 | -------------------------------------------------------------------------------- /lib/memfs/fake/entry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MemFs 4 | module Fake 5 | class Entry 6 | UREAD = 0o0400 7 | UWRITE = 0o0200 8 | UEXEC = 0o0100 9 | GREAD = 0o0040 10 | GWRITE = 0o0020 11 | GEXEC = 0o0010 12 | OREAD = 0o0004 13 | OWRITE = 0o0002 14 | OEXEC = 0o0001 15 | RSTICK = 0o1000 16 | USTICK = 0o5000 17 | SETUID = 0o4000 18 | SETGID = 0o2000 19 | 20 | attr_accessor :atime, 21 | :birthtime, 22 | :block_device, 23 | :character_device, 24 | :ctime, 25 | :gid, 26 | :mtime, 27 | :name, 28 | :parent, 29 | :uid 30 | attr_reader :mode 31 | 32 | def blksize 33 | 4096 34 | end 35 | 36 | def delete 37 | parent.remove_entry self 38 | end 39 | 40 | def dereferenced 41 | self 42 | end 43 | 44 | def dereferenced_name 45 | name 46 | end 47 | 48 | def dereferenced_path 49 | path 50 | end 51 | 52 | def dev 53 | @dev ||= rand(1000) 54 | end 55 | 56 | def fileno 57 | fail NotImplementedError 58 | end 59 | 60 | def find(_path) 61 | fail Errno::ENOTDIR, path 62 | end 63 | 64 | def initialize(path = nil) 65 | time = Time.now 66 | 67 | self.atime = time 68 | self.birthtime = time 69 | self.ctime = time 70 | self.gid = Process.egid 71 | self.mode = 0o666 - MemFs::File.umask 72 | self.mtime = time 73 | self.name = MemFs::File.basename(path || '') 74 | self.uid = Process.euid 75 | end 76 | 77 | def ino 78 | @ino ||= rand(1000) 79 | end 80 | 81 | def mode=(mode_int) 82 | @mode = 0o100000 | mode_int 83 | end 84 | 85 | def path 86 | parts = [parent&.path, name].compact 87 | MemFs::File.join(parts) 88 | end 89 | 90 | def paths 91 | [path] 92 | end 93 | 94 | def touch 95 | self.atime = self.mtime = Time.now 96 | end 97 | 98 | def type 99 | 'unknown' 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## HEAD 4 | 5 | * ADD: Support for Ruby 3.x 6 | * ADD: `Dir.empty?` 7 | * ADD: `IO#fileno` and `Dir#fileno` raise `NotImplementedError` 8 | * ADD: `File.birthtime` and `File#birthtime` 9 | * ADD: `File.empty?` 10 | * ADD: `File::Stat#nlink` (#39 by @djberg96) 11 | * FIX: Fixing the inverted _read_ and _execute_ bitmasks (#41 by @micahlee) 12 | * ADD: Dependabot configuration 13 | * CHG: Replacing Travis CI with GitHub Actions 14 | 15 | ### Breaking 16 | 17 | * DEL: Removing support for Ruby 2.4 and 2.5 18 | * CHG: Renaming the `master` branch to `main` 19 | 20 | ## 1.0.0 21 | 22 | :warning: This version drops support for Ruby 1.9. 23 | 24 | * ADD: Support for Ruby 2.4.0 25 | * ADD: Support for _Pathname_ in `Dir.glob` (PR #21 by @craigw) 26 | * ADD: `MemFs.halt` to switch back to the real file-system (PR #24 by @thsur) 27 | * ADD: Basic support for `IO.write` (PR #20 by @rmm5t) 28 | * FIX: Reset the file position when reopened (PR #23 by @jimpo) 29 | * FIX: Ignore trailing slashes when searching an entry (issue #26) 30 | * FIX: Making `File` inherit from `IO` to fix 3rd-party related issues 31 | * FIX: Ensure `File.new` on a symlink raises if target is absent 32 | 33 | ## 0.5.0 34 | 35 | * ADD: Support for _mode_ to `Dir.mkdir`, `FileUtils.mkdir` and `FileUtils.mkdir_p` (@raeno) 36 | * ADD: Support for Ruby 2.2 (@raeno) 37 | 38 | ## 0.4.3 39 | 40 | * ADD: `File::SEPARATOR` and `File::ALT_SEPARATOR` 41 | * FIX: Support `YAML.load_file` by handling `r:bom|utf-8` open mode 42 | 43 | ## 0.4.2 44 | 45 | * ADD: `File#external_encoding` 46 | * FIX: Undefined local variable or method `fs' for MemFs::File 47 | 48 | ## 0.4.1 49 | 50 | * FIX: Support for 1.9.3 broken by File::FNM_EXTGLOB 51 | 52 | ## 0.4.0 53 | 54 | * ADD: `Dir.chroot` 55 | * ADD: `Dir.glob` and `Dir[]` 56 | * ADD: `Dir.open` 57 | * ADD: `Dir.tmpdir` 58 | * ADD: `Dir#close` 59 | * ADD: `Dir#path` 60 | * ADD: `Dir#pos=` 61 | * ADD: `Dir#pos` 62 | * ADD: `Dir#read` 63 | * ADD: `Dir#rewind` 64 | * ADD: `Dir#seek` 65 | * ADD: `Dir#tell` 66 | * ADD: `Dir#to_path` 67 | * FIX: Internal implementation methods are now private 68 | 69 | ## 0.3.0 70 | 71 | * FIX: The gem is now Ruby 1.9 compatible 72 | 73 | ## 0.2.0 74 | 75 | * ADD: Allowing magic creation of files with `MemFs.touch` 76 | * ADD: `Dir#each` 77 | * ADD: `Dir.delete` 78 | * ADD: `Dir.exist?` 79 | * ADD: `Dir.foreach` 80 | * ADD: `Dir.home` 81 | * ADD: `Dir.new` 82 | * ADD: `Dir.unlink` 83 | * FIX: File.new now truncates a file when opening mode says so 84 | 85 | ## 0.1.0 86 | 87 | * ADD: Adding `File` missing methods - #3 88 | 89 | ## 0.0.2 90 | 91 | * ADD: Adding the MIT license to the gemspec file - #2 92 | -------------------------------------------------------------------------------- /spec/memfs_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe MemFs do 4 | describe '.activate' do 5 | it 'calls the given block with MemFs activated' do 6 | described_class.activate do 7 | expect(::Dir).to be(described_class::Dir) 8 | end 9 | end 10 | 11 | it 'resets the original classes once finished' do 12 | described_class.activate {} 13 | expect(::Dir).to be(described_class::OriginalDir) 14 | end 15 | 16 | it 'deactivates MemFs even when an exception occurs' do 17 | begin 18 | described_class.activate { fail 'Some error' } 19 | rescue RuntimeError 20 | end 21 | expect(::Dir).to be(described_class::OriginalDir) 22 | end 23 | end 24 | 25 | describe '.activate!' do 26 | before(:each) { described_class.activate! } 27 | after(:each) { described_class.deactivate! } 28 | 29 | it 'replaces Ruby Dir class with a fake one' do 30 | expect(::Dir).to be(described_class::Dir) 31 | end 32 | 33 | it 'replaces Ruby File class with a fake one' do 34 | expect(::File).to be(described_class::File) 35 | end 36 | end 37 | 38 | describe '.deactivate!' do 39 | before :each do 40 | described_class.activate! 41 | described_class.deactivate! 42 | end 43 | 44 | it 'sets back the Ruby Dir class to the original one' do 45 | expect(::Dir).to be(described_class::OriginalDir) 46 | end 47 | 48 | it 'sets back the Ruby File class to the original one' do 49 | expect(::File).to be(described_class::OriginalFile) 50 | end 51 | end 52 | 53 | describe '.halt' do 54 | before(:each) { described_class.activate! } 55 | after(:each) { described_class.deactivate! } 56 | 57 | it 'switches back to the original Ruby Dir & File classes' do 58 | described_class.halt do 59 | expect(::Dir).to be(described_class::OriginalDir) 60 | expect(::File).to be(described_class::OriginalFile) 61 | end 62 | end 63 | 64 | it 'switches back to the faked Dir & File classes' do 65 | described_class.halt 66 | expect(::Dir).to be(described_class::Dir) 67 | expect(::File).to be(described_class::File) 68 | end 69 | 70 | it 'switches back to the faked Dir & File classes no matter what' do 71 | begin 72 | described_class.halt { fail 'Fatal Error' } 73 | rescue 74 | expect(::Dir).to be(described_class::Dir) 75 | expect(::File).to be(described_class::File) 76 | end 77 | end 78 | 79 | it 'maintains the state of the faked fs' do 80 | _fs.touch('file.rb') 81 | 82 | described_class.halt do 83 | expect(File.exist?('file.rb')).to be false 84 | end 85 | 86 | expect(File.exist?('file.rb')).to be true 87 | end 88 | end 89 | 90 | describe '.touch' do 91 | around(:each) { |example| described_class.activate { example.run } } 92 | 93 | it 'creates the specified file' do 94 | _fs.mkdir('/path') 95 | _fs.mkdir('/path/to') 96 | _fs.mkdir('/path/to/some') 97 | described_class.touch('/path/to/some/file.rb') 98 | expect(File.exist?('/path/to/some/file.rb')).to be true 99 | end 100 | 101 | context 'when the parent folder do not exist' do 102 | it 'creates them all' do 103 | described_class.touch('/path/to/some/file.rb') 104 | expect(File.exist?('/path/to/some/file.rb')).to be true 105 | end 106 | end 107 | 108 | context 'when several files are specified' do 109 | it 'creates every file' do 110 | described_class.touch('/some/path', '/other/path') 111 | expect(File.exist?('/some/path')).to be true 112 | expect(File.exist?('/other/path')).to be true 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/memfs/file_system.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | require 'memfs/fake/directory' 5 | require 'memfs/fake/file' 6 | require 'memfs/fake/symlink' 7 | 8 | module MemFs 9 | class FileSystem 10 | include Singleton 11 | 12 | attr_accessor :registred_entries, 13 | :root, 14 | :working_directory 15 | 16 | def basename(path) 17 | File.basename(path) 18 | end 19 | 20 | def chdir(path) 21 | destination = find_directory!(path) 22 | 23 | previous_directory = working_directory 24 | self.working_directory = destination 25 | 26 | yield if block_given? 27 | ensure 28 | self.working_directory = previous_directory if block_given? 29 | end 30 | 31 | def clear! 32 | self.root = Fake::Directory.new('/') 33 | mkdir '/tmp' 34 | chdir '/' 35 | end 36 | 37 | def chmod(mode_int, file_name) 38 | find!(file_name).mode = mode_int 39 | end 40 | 41 | def chown(uid, gid, path) 42 | entry = find!(path).dereferenced 43 | entry.uid = uid if uid && uid != -1 44 | entry.gid = gid if gid && gid != -1 45 | end 46 | 47 | def dirname(path) 48 | File.dirname(path) 49 | end 50 | 51 | def entries(path) 52 | find_directory!(path).entry_names 53 | end 54 | 55 | def find(path) 56 | if path == '/' 57 | root 58 | elsif dirname(path) == '.' 59 | working_directory.find(path) 60 | else 61 | root.find(path) 62 | end 63 | end 64 | 65 | def find!(path) 66 | find(path) || fail(Errno::ENOENT, path) 67 | end 68 | 69 | def find_directory!(path) 70 | entry = find!(path).dereferenced 71 | 72 | fail Errno::ENOTDIR, path unless entry.is_a?(Fake::Directory) 73 | 74 | entry 75 | end 76 | 77 | def find_parent!(path) 78 | parent_path = dirname(path) 79 | find_directory!(parent_path) 80 | end 81 | 82 | def getwd 83 | working_directory.path 84 | end 85 | alias pwd getwd 86 | 87 | def initialize 88 | clear! 89 | end 90 | 91 | def link(old_name, new_name) 92 | file = find!(old_name) 93 | 94 | fail Errno::EEXIST, "(#{old_name}, #{new_name})" if find(new_name) 95 | 96 | link = file.dup 97 | link.name = basename(new_name) 98 | find_parent!(new_name).add_entry link 99 | end 100 | 101 | def mkdir(path, mode = 0o777) 102 | fail Errno::EEXIST, path if find(path) 103 | directory = Fake::Directory.new(path) 104 | directory.mode = mode 105 | find_parent!(path).add_entry directory 106 | end 107 | 108 | def paths 109 | root.paths 110 | end 111 | 112 | def rename(old_name, new_name) 113 | file = find!(old_name) 114 | file.delete 115 | 116 | file.name = basename(new_name) 117 | find_parent!(new_name).add_entry(file) 118 | end 119 | 120 | def rmdir(path) 121 | directory = find!(path) 122 | 123 | fail Errno::ENOTEMPTY, path unless directory.empty? 124 | 125 | directory.delete 126 | end 127 | 128 | def symlink(old_name, new_name) 129 | fail Errno::EEXIST, new_name if find(new_name) 130 | 131 | find_parent!(new_name).add_entry Fake::Symlink.new(new_name, old_name) 132 | end 133 | 134 | def touch(*paths) 135 | paths.each do |path| 136 | entry = find(path) 137 | 138 | unless entry 139 | entry = Fake::File.new(path) 140 | parent_dir = find_parent!(path) 141 | parent_dir.add_entry entry 142 | end 143 | 144 | entry.touch 145 | end 146 | end 147 | 148 | def unlink(path) 149 | entry = find!(path) 150 | 151 | fail Errno::EPERM, path if entry.is_a?(Fake::Directory) 152 | 153 | entry.delete 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/memfs/fake/symlink_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MemFs 4 | module Fake 5 | ::RSpec.describe Symlink do 6 | describe '#content' do 7 | it "returns the target's content" do 8 | MemFs::File.open('/test-file', 'w') { |f| f.puts 'test' } 9 | s = described_class.new('/test-link', '/test-file') 10 | expect(s.content).to be(s.dereferenced.content) 11 | end 12 | end 13 | 14 | describe '#dereferenced' do 15 | it "returns the target if it's not a symlink" do 16 | _fs.touch '/test-file' 17 | target = _fs.find!('/test-file') 18 | 19 | s = described_class.new('/test-link', '/test-file') 20 | 21 | expect(s.dereferenced).to eq(target) 22 | end 23 | 24 | it 'returns the last target of the chain' do 25 | _fs.touch '/test-file' 26 | target = _fs.find!('/test-file') 27 | 28 | _fs.symlink '/test-file', '/test-link' 29 | s = described_class.new('/test-link2', '/test-link') 30 | 31 | expect(s.dereferenced).to eq(target) 32 | end 33 | end 34 | 35 | describe '#dereferenced_name' do 36 | context "when the symlink's target exists" do 37 | it 'returns its target name' do 38 | _fs.touch('/test-file') 39 | symlink = described_class.new('/test-link', '/test-file') 40 | expect(symlink.dereferenced_name).to eq('test-file') 41 | end 42 | end 43 | 44 | context "when the symlink's target does not exist" do 45 | it 'returns its target name' do 46 | symlink = described_class.new('/test-link', '/no-file') 47 | expect(symlink.dereferenced_name).to eq('no-file') 48 | end 49 | end 50 | end 51 | 52 | describe '#dereferenced_path' do 53 | context "when the symlink's target exists" do 54 | it 'returns its target path' do 55 | _fs.touch('/test-file') 56 | symlink = described_class.new('/test-link', '/test-file') 57 | expect(symlink.dereferenced_path).to eq('/test-file') 58 | end 59 | end 60 | 61 | context "when the symlink's target does not exist" do 62 | it 'raises an exception' do 63 | symlink = described_class.new('/test-link', '/no-file') 64 | expect { 65 | symlink.dereferenced_path 66 | }.to raise_error Errno::ENOENT 67 | end 68 | end 69 | end 70 | 71 | describe '#find' do 72 | let(:file) { _fs.find!('/test-dir/test-file') } 73 | 74 | before :each do 75 | _fs.mkdir '/test-dir' 76 | _fs.touch '/test-dir/test-file' 77 | end 78 | 79 | context "when the symlink's target exists" do 80 | subject { described_class.new('/test-dir-link', '/test-dir') } 81 | 82 | it 'forwards the search to it' do 83 | entry = subject.find('test-file') 84 | expect(entry).to eq(file) 85 | end 86 | end 87 | 88 | context "when the symlink's target does not exist" do 89 | subject { described_class.new('/test-no-link', '/no-dir') } 90 | 91 | it 'returns nil' do 92 | entry = subject.find('test-file') 93 | expect(entry).to be_nil 94 | end 95 | end 96 | end 97 | 98 | describe '#target' do 99 | it 'returns the target of the symlink' do 100 | s = described_class.new('/test-link', '/test-file') 101 | expect(s.target).to eq('/test-file') 102 | end 103 | end 104 | 105 | describe '#type' do 106 | it "returns 'link'" do 107 | s = described_class.new('/test-link', '/test-file') 108 | expect(s.type).to eq('link') 109 | end 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/memfs/dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'memfs/filesystem_access' 4 | 5 | module MemFs 6 | class Dir 7 | extend FilesystemAccess 8 | include Enumerable 9 | include FilesystemAccess 10 | 11 | attr_reader :pos 12 | 13 | def self.[](*patterns) 14 | glob(patterns) 15 | end 16 | 17 | def self.chdir(path, &block) 18 | fs.chdir path, &block 19 | 0 20 | end 21 | 22 | if MemFs.ruby_version_gte?('2.6') 23 | def self.children(dirname, opts = {}) 24 | entries(dirname, opts) - %w[. ..] 25 | end 26 | end 27 | 28 | def self.chroot(path) 29 | fail Errno::EPERM, path unless Process.uid.zero? 30 | 31 | dir = fs.find_directory!(path) 32 | dir.name = '/' 33 | fs.root = dir 34 | 0 35 | end 36 | 37 | def self.empty?(path) 38 | entry = fs.find!(path) 39 | File.directory?(path) && entry.empty? 40 | end 41 | 42 | def self.entries(dirname, _opts = {}) 43 | fs.entries(dirname) 44 | end 45 | 46 | def self.exists?(path) 47 | File.directory?(path) 48 | end 49 | class << self; alias exist? exists?; end 50 | 51 | def self.foreach(dirname, &block) 52 | return to_enum(__callee__, dirname) unless block 53 | 54 | entries(dirname).each(&block) 55 | end 56 | 57 | def self.getwd 58 | fs.getwd 59 | end 60 | class << self; alias pwd getwd; end 61 | 62 | def self.glob(patterns, flags = 0, &block) 63 | patterns = [*patterns].map(&:to_s) 64 | list = fs.paths.select do |path| 65 | patterns.any? do |pattern| 66 | File.fnmatch?(pattern, path, flags | GLOB_FLAGS) 67 | end 68 | end 69 | # FIXME: ugly special case for /* and / 70 | list.delete('/') if patterns.first == '/*' 71 | return list unless block_given? 72 | list.each { |path| block.call(path) } 73 | nil 74 | end 75 | 76 | def self.home(*args) 77 | original_dir_class.home(*args) 78 | end 79 | 80 | def self.mkdir(path, mode = 0o777) 81 | fs.mkdir path, mode 82 | end 83 | 84 | def self.open(dirname) 85 | dir = new(dirname) 86 | 87 | if block_given? 88 | yield dir 89 | else 90 | dir 91 | end 92 | ensure 93 | dir&.close if block_given? 94 | end 95 | 96 | def self.rmdir(path) 97 | fs.rmdir path 98 | end 99 | 100 | def self.tmpdir 101 | '/tmp' 102 | end 103 | 104 | class << self 105 | alias delete rmdir 106 | alias unlink rmdir 107 | end 108 | 109 | def initialize(path) 110 | self.entry = fs.find_directory!(path) 111 | self.state = :open 112 | @pos = 0 113 | self.max_seek = 0 114 | end 115 | 116 | def close 117 | fail IOError, 'closed directory' if state == :closed 118 | self.state = :closed 119 | end 120 | 121 | def each(&block) 122 | return to_enum(__callee__) unless block 123 | entry.entry_names.each(&block) 124 | end 125 | 126 | def fileno 127 | entry.fileno 128 | end 129 | 130 | def path 131 | entry.path 132 | end 133 | alias to_path path 134 | 135 | # rubocop:disable Lint/Void 136 | def pos=(position) 137 | seek(position) 138 | position 139 | end 140 | # rubocop:enable Lint/Void 141 | 142 | def read 143 | name = entries[pos] 144 | @pos += 1 145 | self.max_seek = pos 146 | name 147 | end 148 | 149 | def rewind 150 | @pos = 0 151 | self 152 | end 153 | 154 | def seek(position) 155 | @pos = position if (0..max_seek).cover?(position) 156 | self 157 | end 158 | 159 | def tell 160 | @pos 161 | end 162 | 163 | private 164 | 165 | GLOB_FLAGS = if defined?(File::FNM_EXTGLOB) 166 | File::FNM_EXTGLOB | File::FNM_PATHNAME 167 | else 168 | File::FNM_PATHNAME 169 | end 170 | 171 | attr_accessor :entry, :max_seek, :state 172 | 173 | def self.original_dir_class 174 | MemFs::OriginalDir 175 | end 176 | private_class_method :original_dir_class 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/memfs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'memfs/version' 4 | require 'fileutils' 5 | 6 | # Provides a clean way to interact with a fake file system. 7 | # 8 | # @example Calling activate with a block. 9 | # MemFs.activate do 10 | # Dir.mkdir '/hello_world' 11 | # # /hello_world exists here, in memory 12 | # end 13 | # # /hello_world doesn't exist and has never been on the real FS 14 | # 15 | # @example Calling activate! and deactivate!. 16 | # MemFs.activate! 17 | # # The fake file system is running here 18 | # MemFs.deactivate! 19 | # # Everything back to normal 20 | module MemFs 21 | # Keeps track of the original Ruby Dir class. 22 | OriginalDir = ::Dir 23 | 24 | # Keeps track of the original Ruby File class. 25 | OriginalFile = ::File 26 | 27 | # Keeps track of the original Ruby IO class. 28 | OriginalIO = ::IO 29 | 30 | def self.ruby_version_gte?(version) # :nodoc: 31 | Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(version) 32 | end 33 | 34 | def self.windows? 35 | /mswin|bccwin|mingw/ =~ RUBY_PLATFORM 36 | end 37 | 38 | require 'memfs/file_system' 39 | require 'memfs/dir' 40 | require 'memfs/file' 41 | require 'memfs/file/stat' 42 | 43 | # Calls the given block with MemFs activated. 44 | # 45 | # The advantage of using {#activate} against {#activate!} is that, in case an 46 | # exception occurs, MemFs is deactivated. 47 | # 48 | # @yield with no argument. 49 | # 50 | # @example 51 | # MemFs.activate do 52 | # Dir.mkdir '/hello_world' 53 | # # /hello_world exists here, in memory 54 | # end 55 | # # /hello_world doesn't exist and has never been on the real FS 56 | # 57 | # @example Exception in activate block. 58 | # MemFs.activate do 59 | # raise "Some Error" 60 | # end 61 | # # Still back to the original Ruby classes 62 | # 63 | # @return nothing. 64 | def activate 65 | activate! 66 | yield 67 | ensure 68 | deactivate! 69 | end 70 | module_function :activate 71 | 72 | # Activates the fake file system. 73 | # 74 | # @note Don't forget to call {#deactivate!} to disable the fake file system, 75 | # you may have some issues in your scripts or tests otherwise. 76 | # 77 | # @example 78 | # MemFs.activate! 79 | # Dir.mkdir '/hello_world' 80 | # # /hello_world exists here, in memory 81 | # MemFs.deactivate! 82 | # # /hello_world doesn't exist and has never been on the real FS 83 | # 84 | # @see #deactivate! 85 | # @return nothing. 86 | def activate!(clear: true) 87 | Object.class_eval do 88 | remove_const :Dir 89 | remove_const :File 90 | remove_const :IO 91 | 92 | const_set :Dir, MemFs::Dir 93 | const_set :IO, MemFs::IO 94 | const_set :File, MemFs::File 95 | end 96 | 97 | MemFs::FileSystem.instance.clear! if clear 98 | end 99 | module_function :activate! 100 | 101 | # Deactivates the fake file system. 102 | # 103 | # @note This method should always be called when using activate! 104 | # 105 | # @see #activate! 106 | # @return nothing. 107 | def deactivate! 108 | Object.class_eval do 109 | remove_const :Dir 110 | remove_const :File 111 | remove_const :IO 112 | 113 | const_set :Dir, MemFs::OriginalDir 114 | const_set :IO, MemFs::OriginalIO 115 | const_set :File, MemFs::OriginalFile 116 | end 117 | end 118 | module_function :deactivate! 119 | 120 | # Switches back to the original file system, calls the given block (if any), 121 | # and switches back afterwards. 122 | # 123 | # If a block is given, all file & dir operations (like reading dir contents or 124 | # requiring files) will operate on the original fs. 125 | # 126 | # @example 127 | # MemFs.halt do 128 | # puts Dir.getwd 129 | # end 130 | # @return nothing 131 | def halt 132 | deactivate! 133 | 134 | yield if block_given? 135 | ensure 136 | activate!(clear: false) 137 | end 138 | module_function :halt 139 | 140 | # Creates a file and all its parent directories. 141 | # 142 | # @param path: The path of the file to create. 143 | # 144 | # @return nothing. 145 | def touch(*paths) 146 | fail 'Always call MemFs.touch inside a MemFs active context.' if ::File != MemFs::File 147 | 148 | paths.each do |path| 149 | FileUtils.mkdir_p File.dirname(path) 150 | FileUtils.touch path 151 | end 152 | end 153 | module_function :touch 154 | end 155 | -------------------------------------------------------------------------------- /spec/memfs/fake/directory_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MemFs 4 | module Fake 5 | ::RSpec.describe Directory do 6 | subject(:directory) { described_class.new('test') } 7 | 8 | describe '.new' do 9 | it 'sets . in the entries list' do 10 | expect(directory.entries).to include('.' => directory) 11 | end 12 | 13 | it 'sets .. in the entries list' do 14 | expect(directory.entries).to have_key('..') 15 | end 16 | end 17 | 18 | describe '#add_entry' do 19 | let(:entry) { described_class.new('new_entry') } 20 | 21 | it 'adds the entry to the entries list' do 22 | directory.add_entry entry 23 | expect(directory.entries).to include('new_entry' => entry) 24 | end 25 | 26 | it 'sets the parent of the added entry' do 27 | directory.add_entry entry 28 | expect(entry.parent).to be(directory) 29 | end 30 | end 31 | 32 | describe 'empty?' do 33 | it 'returns true if the directory is empty' do 34 | expect(directory).to be_empty 35 | end 36 | 37 | it 'returns false if the directory is not empty' do 38 | directory.add_entry described_class.new('test') 39 | expect(directory).not_to be_empty 40 | end 41 | end 42 | 43 | describe '#entry_names' do 44 | it 'returns the list of the names of the entries in the directory' do 45 | 3.times do |n| 46 | directory.add_entry described_class.new("dir#{n}") 47 | end 48 | 49 | expect(directory.entry_names).to eq(%w[. .. dir0 dir1 dir2]) 50 | end 51 | end 52 | 53 | describe '#find' do 54 | let(:sub_directory) { described_class.new('sub_dir') } 55 | let(:file) { File.new('file') } 56 | 57 | before :each do 58 | sub_directory.add_entry file 59 | directory.add_entry sub_directory 60 | end 61 | 62 | it 'returns the named entry if it is one of the entries' do 63 | expect(directory.find('sub_dir')).to be(sub_directory) 64 | end 65 | 66 | it 'calls find on the next directory in the search chain' do 67 | expect(directory.find('sub_dir/file')).to be(file) 68 | end 69 | 70 | it 'should remove any leading / in the path' do 71 | expect(directory.find('/sub_dir/file')).to be(file) 72 | end 73 | 74 | it 'should remove any trailing / in the path' do 75 | expect(directory.find('sub_dir/file/')).to be(file) 76 | end 77 | end 78 | 79 | describe '#parent=' do 80 | let(:parent) { described_class.new('parent') } 81 | 82 | it 'sets the .. entry in entries list' do 83 | directory.parent = parent 84 | expect(directory.entries).to include('..' => parent) 85 | end 86 | 87 | it 'sets the parent directory' do 88 | directory.parent = parent 89 | expect(directory.parent).to be(parent) 90 | end 91 | end 92 | 93 | describe '#path' do 94 | let(:root) { described_class.new('/') } 95 | 96 | it 'returns the directory path' do 97 | directory.parent = root 98 | expect(directory.path).to eq('/test') 99 | end 100 | 101 | context 'when the directory is /' do 102 | it 'returns /' do 103 | expect(root.path).to eq('/') 104 | end 105 | end 106 | end 107 | 108 | describe '#paths' do 109 | before do 110 | subdir = described_class.new('subdir') 111 | directory.add_entry(subdir) 112 | subdir.add_entry File.new('file1') 113 | subdir.add_entry File.new('file2') 114 | end 115 | 116 | it 'returns the path of the directory and its entries recursively' do 117 | expect(directory.paths).to eq \ 118 | %w[test test/subdir test/subdir/file1 test/subdir/file2] 119 | end 120 | end 121 | 122 | describe '#remove_entry' do 123 | let(:file) { File.new('file') } 124 | 125 | it 'removes an entry from the entries list' do 126 | directory.add_entry file 127 | directory.remove_entry file 128 | expect(directory.entries).not_to have_value(file) 129 | end 130 | end 131 | 132 | describe '#type' do 133 | it "returns 'directory'" do 134 | expect(directory.type).to eq('directory') 135 | end 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/memfs/file/stat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'memfs/filesystem_access' 5 | 6 | module MemFs 7 | class File 8 | class Stat 9 | extend Forwardable 10 | include FilesystemAccess 11 | 12 | attr_reader :entry 13 | 14 | def_delegators :entry, 15 | :atime, 16 | :birthtime, 17 | :blksize, 18 | :ctime, 19 | :dev, 20 | :gid, 21 | :ino, 22 | :mode, 23 | :mtime, 24 | :uid 25 | 26 | def blockdev? 27 | !!entry.block_device 28 | end 29 | 30 | def chardev? 31 | !!entry.character_device 32 | end 33 | 34 | def directory? 35 | entry.is_a?(Fake::Directory) 36 | end 37 | 38 | def executable? 39 | user_executable? || group_executable? || !!world_executable? 40 | end 41 | 42 | def executable_real? 43 | user_executable_real? || group_executable_real? || !!world_executable? 44 | end 45 | 46 | def file? 47 | entry.is_a?(Fake::File) 48 | end 49 | 50 | def ftype 51 | entry.type 52 | end 53 | 54 | def grpowned? 55 | gid == Process.egid 56 | end 57 | 58 | def initialize(path, dereference: false) 59 | entry = fs.find!(path) 60 | @entry = dereference ? entry.dereferenced : entry 61 | end 62 | 63 | def nlink 64 | directory? ? 2 : 1 65 | end 66 | 67 | def owned? 68 | uid == Process.euid 69 | end 70 | 71 | def pipe? 72 | false 73 | end 74 | 75 | def readable? 76 | user_readable? || group_readable? || !!world_readable? 77 | end 78 | 79 | def readable_real? 80 | user_readable_real? || group_readable_real? || !!world_readable? 81 | end 82 | 83 | def setgid? 84 | !!(entry.mode & Fake::Entry::SETGID).nonzero? 85 | end 86 | 87 | def setuid? 88 | !!(entry.mode & Fake::Entry::SETUID).nonzero? 89 | end 90 | 91 | def socket? 92 | false 93 | end 94 | 95 | def sticky? 96 | !!(entry.mode & Fake::Entry::USTICK).nonzero? 97 | end 98 | 99 | def symlink? 100 | entry.is_a?(Fake::Symlink) 101 | end 102 | 103 | def world_readable? 104 | entry.mode - 0o100000 if (entry.mode & Fake::Entry::OREAD).nonzero? 105 | end 106 | 107 | def world_writable? 108 | entry.mode - 0o100000 if (entry.mode & Fake::Entry::OWRITE).nonzero? 109 | end 110 | 111 | def writable? 112 | user_writable? || group_writable? || !!world_writable? 113 | end 114 | 115 | def writable_real? 116 | user_writable_real? || group_writable_real? || !!world_writable? 117 | end 118 | 119 | def zero? 120 | !!(entry.content && entry.content.empty?) 121 | end 122 | 123 | private 124 | 125 | def group_executable? 126 | grpowned? && !!(mode & Fake::Entry::GEXEC).nonzero? 127 | end 128 | 129 | def group_executable_real? 130 | Process.gid == gid && !!(mode & Fake::Entry::GEXEC).nonzero? 131 | end 132 | 133 | def group_readable? 134 | grpowned? && !!(mode & Fake::Entry::GREAD).nonzero? 135 | end 136 | 137 | def group_readable_real? 138 | Process.gid == gid && !!(mode & Fake::Entry::GREAD).nonzero? 139 | end 140 | 141 | def group_writable? 142 | grpowned? && !!(mode & Fake::Entry::GWRITE).nonzero? 143 | end 144 | 145 | def group_writable_real? 146 | Process.gid == gid && !!(mode & Fake::Entry::GWRITE).nonzero? 147 | end 148 | 149 | def user_executable? 150 | owned? && !!(mode & Fake::Entry::UEXEC).nonzero? 151 | end 152 | 153 | def user_executable_real? 154 | Process.uid == uid && !!(mode & Fake::Entry::UEXEC).nonzero? 155 | end 156 | 157 | def user_readable? 158 | owned? && !!(mode & Fake::Entry::UREAD).nonzero? 159 | end 160 | 161 | def user_readable_real? 162 | Process.uid == uid && !!(mode & Fake::Entry::UREAD).nonzero? 163 | end 164 | 165 | def user_writable? 166 | owned? && !!(mode & Fake::Entry::UWRITE).nonzero? 167 | end 168 | 169 | def user_writable_real? 170 | Process.uid == uid && !!(mode & Fake::Entry::UWRITE).nonzero? 171 | end 172 | 173 | def world_executable? 174 | entry.mode - 0o100000 if (entry.mode & Fake::Entry::OEXEC).nonzero? 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /spec/memfs/fake/file/content_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | module MemFs 5 | module Fake 6 | ::RSpec.describe File::Content do 7 | describe '#<<' do 8 | it 'writes the given string to the contained string' do 9 | subject << 'test' 10 | expect(subject.to_s).to eq('test') 11 | end 12 | end 13 | 14 | describe '#initialize' do 15 | context 'when no argument is given' do 16 | it 'initialize the contained string to an empty one' do 17 | expect(subject.to_s).to eq('') 18 | end 19 | end 20 | 21 | context 'when an argument is given' do 22 | subject { described_class.new(base_value) } 23 | 24 | context 'when the argument is a string' do 25 | let(:base_value) { 'test' } 26 | 27 | it 'initialize the contained string with the given one' do 28 | expect(subject.to_s).to eq('test') 29 | end 30 | 31 | it 'duplicates the original string to prevent modifications on it' do 32 | expect(subject.to_s).not_to be(base_value) 33 | end 34 | end 35 | 36 | context 'when the argument is not a string' do 37 | let(:base_value) { 42 } 38 | 39 | it 'converts it to a string' do 40 | expect(subject.to_s).to eq('42') 41 | end 42 | end 43 | end 44 | end 45 | 46 | describe '#puts' do 47 | it 'appends the given string to the contained string' do 48 | subject.puts 'test' 49 | expect(subject.to_s).to eq("test\n") 50 | end 51 | 52 | it 'appends all given strings to the contained string' do 53 | subject.puts 'this', 'is', 'a', 'test' 54 | expect(subject.to_s).to eq("this\nis\na\ntest\n") 55 | end 56 | 57 | context 'when a line break is present at the end of the given string' do 58 | it "doesn't add any line break" do 59 | subject.puts "test\n" 60 | expect(subject.to_s).not_to eq("test\n\n") 61 | end 62 | end 63 | end 64 | 65 | describe '#to_s' do 66 | context 'when the content is empty' do 67 | it 'returns an empty string' do 68 | expect(subject.to_s).to eq('') 69 | end 70 | end 71 | 72 | context 'when the content is not empty' do 73 | it "returns the content's string" do 74 | subject << 'test' 75 | expect(subject.to_s).to eq('test') 76 | end 77 | end 78 | end 79 | 80 | describe '#truncate' do 81 | subject { described_class.new('x' * 50) } 82 | 83 | it 'truncates the content to length characters' do 84 | subject.truncate(5) 85 | expect(subject.length).to eq(5) 86 | end 87 | end 88 | 89 | describe '#write' do 90 | it 'writes the given string in content' do 91 | subject.write 'test' 92 | expect(subject.to_s).to eq('test') 93 | end 94 | 95 | it 'returns the number of bytes written' do 96 | expect(subject.write('test')).to eq(4) 97 | end 98 | 99 | context 'when the argument is not a string' do 100 | it 'converts it to a string' do 101 | subject.write 42 102 | expect(subject.to_s).to eq('42') 103 | end 104 | end 105 | 106 | context 'when the argument is a non-ascii string' do 107 | it 'returns the correct number of bytes written' do 108 | expect(subject.write('é')).to eq(1) 109 | end 110 | end 111 | end 112 | 113 | context 'when initialized with a string argument' do 114 | subject { described_class.new('test') } 115 | 116 | describe '#read' do 117 | it 'reads +length+ bytes from the contained string' do 118 | expect(subject.read(2)).to eq('te') 119 | end 120 | 121 | context 'when there is nothing else to read' do 122 | it 'returns nil' do 123 | subject.read 4 124 | expect(subject.read(1)).to be_nil 125 | end 126 | end 127 | 128 | context 'when the optional +buffer+ argument is provided' do 129 | it 'inserts the output in the buffer' do 130 | string = String.new 131 | subject.read(2, string) 132 | expect(string).to eq('te') 133 | end 134 | end 135 | end 136 | 137 | describe '#pos' do 138 | context 'when the string has not been read' do 139 | it 'returns 0' do 140 | expect(subject.pos).to eq(0) 141 | end 142 | end 143 | 144 | context 'when the string has been read' do 145 | it 'returns the current offset' do 146 | subject.read 2 147 | expect(subject.pos).to eq(2) 148 | end 149 | end 150 | end 151 | 152 | describe '#close' do 153 | it 'responds to close' do 154 | expect(subject).to respond_to(:close) 155 | end 156 | end 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/memfs/fake/entry_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MemFs 4 | module Fake 5 | ::RSpec.describe Entry do 6 | let(:entry) { described_class.new('test') } 7 | let(:parent) { Directory.new('parent') } 8 | let(:time) { Time.now - 5000 } 9 | 10 | before(:each) do 11 | parent.parent = Directory.new('/') 12 | entry.parent = parent 13 | end 14 | 15 | shared_examples 'it has accessors for' do |attribute| 16 | let(:expected_value) { defined?(expected) ? expected : value } 17 | 18 | it attribute do 19 | entry.send(:"#{attribute}=", value) 20 | expect(entry.public_send(attribute)).to eq(expected_value) 21 | end 22 | end 23 | 24 | it_behaves_like 'it has accessors for', :name do 25 | let(:value) { 'test' } 26 | end 27 | 28 | it_behaves_like 'it has accessors for', :atime do 29 | let(:value) { time } 30 | end 31 | 32 | it_behaves_like 'it has accessors for', :block_device do 33 | let(:value) { true } 34 | end 35 | 36 | it_behaves_like 'it has accessors for', :character_device do 37 | let(:value) { true } 38 | end 39 | 40 | it_behaves_like 'it has accessors for', :ctime do 41 | let(:value) { time } 42 | end 43 | 44 | it_behaves_like 'it has accessors for', :mtime do 45 | let(:value) { time } 46 | end 47 | 48 | it_behaves_like 'it has accessors for', :uid do 49 | let(:value) { 42 } 50 | end 51 | 52 | it_behaves_like 'it has accessors for', :gid do 53 | let(:value) { 42 } 54 | end 55 | 56 | it_behaves_like 'it has accessors for', :mode do 57 | let(:value) { 0o777 } 58 | let(:expected) { 0o100777 } 59 | end 60 | 61 | it_behaves_like 'it has accessors for', :parent do 62 | let(:value) { parent } 63 | end 64 | 65 | describe '.new' do 66 | it "sets its default uid to the current user's uid" do 67 | expect(entry.uid).to eq(Process.euid) 68 | end 69 | 70 | it "sets its default gid to the current user's gid" do 71 | expect(entry.gid).to eq(Process.egid) 72 | end 73 | 74 | it 'extract its name from the path passed as argument' do 75 | expect(entry.name).to eq('test') 76 | end 77 | 78 | it 'sets an empty string as name if none is given' do 79 | expect(described_class.new.name).to be_empty 80 | end 81 | 82 | it 'sets the access time' do 83 | expect(described_class.new.atime).to be_a(Time) 84 | end 85 | 86 | it 'sets the modification time' do 87 | expect(entry.mtime).to be_a(Time) 88 | end 89 | 90 | it 'sets atime and mtime to the same value' do 91 | expect(entry.atime).to eq(entry.mtime) 92 | end 93 | end 94 | 95 | describe '#delete' do 96 | it 'removes the entry from its parent' do 97 | entry.delete 98 | expect(parent.entries).not_to have_value(entry) 99 | end 100 | end 101 | 102 | describe '#dereferenced' do 103 | it 'returns the entry itself' do 104 | expect(entry.dereferenced).to be(entry) 105 | end 106 | end 107 | 108 | describe '#dereferenced_name' do 109 | it 'returns the entry name' do 110 | expect(entry.dereferenced_name).to eq('test') 111 | end 112 | end 113 | 114 | describe '#dereferenced_path' do 115 | it 'returns the entry path' do 116 | expect(entry.dereferenced_path).to eq('/parent/test') 117 | end 118 | end 119 | 120 | describe '#fileno' do 121 | it 'raises an exception' do 122 | expect { subject.fileno }.to raise_exception(NotImplementedError) 123 | end 124 | end 125 | 126 | describe '#find' do 127 | it 'raises an error' do 128 | expect { entry.find('test') }.to raise_error(Errno::ENOTDIR) 129 | end 130 | end 131 | 132 | describe '#dev' do 133 | it 'returns an integer representing the device on which the entry resides' do 134 | expect(entry.dev).to be_a(Integer) 135 | end 136 | end 137 | 138 | describe '#ino' do 139 | it 'Returns the inode number for the entry' do 140 | expect(entry.ino).to be_a(Integer) 141 | end 142 | end 143 | 144 | describe '#path' do 145 | it 'returns the complete path of the entry' do 146 | expect(entry.path).to eq('/parent/test') 147 | end 148 | end 149 | 150 | describe 'paths' do 151 | it 'returns an array containing the entry path' do 152 | expect(entry.paths).to eq ['/parent/test'] 153 | end 154 | end 155 | 156 | describe '#touch' do 157 | let(:time) { Time.now - 5000 } 158 | 159 | before :each do 160 | entry.atime = time 161 | entry.mtime = time 162 | end 163 | 164 | it 'sets the access time to now' do 165 | entry.touch 166 | expect(entry.atime).not_to eq(time) 167 | end 168 | 169 | it 'sets the modification time to now' do 170 | entry.touch 171 | expect(entry.mtime).not_to eq(time) 172 | end 173 | end 174 | 175 | describe '#type' do 176 | it "returns 'unknown" do 177 | expect(entry.type).to eq('unknown') 178 | end 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![MemFs Logo](https://raw.github.com/simonc/memfs/main/memfs.png) 2 | 3 | [![Gem Version](https://badge.fury.io/rb/memfs.svg)](https://badge.fury.io/rb/memfs) 4 | [![Build Status](https://github.com/simonc/memfs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/simonc/memfs/actions/workflows/ci.yml) 5 | [![Code Climate](https://codeclimate.com/github/simonc/memfs/badges/gpa.svg)](https://codeclimate.com/github/simonc/memfs) 6 | [![Coverage Status](https://coveralls.io/repos/github/simonc/memfs/badge.svg?branch=main)](https://coveralls.io/github/simonc/memfs?branch=main) 7 | 8 | MemFs is an in-memory filesystem that can be used for your tests. 9 | 10 | When you're writing code that manipulates files, directories, symlinks, you need 11 | to be able to test it without touching your hard drive. MemFs is made for it. 12 | 13 | MemFs is intended for tests but you can use it for any other scenario needing in 14 | memory file system. 15 | 16 | MemFs is greatly inspired by the awesome 17 | [FakeFs](https://github.com/defunkt/fakefs). 18 | 19 | The main goal of MemFs is to be 100% compatible with the Ruby libraries like 20 | FileUtils. 21 | 22 | For French people, the answer is yes, the joke in the name is intended ;) 23 | 24 | ## Take a look 25 | 26 | Here is a simple example of MemFs usage: 27 | 28 | ``` ruby 29 | MemFs.activate! 30 | File.open('/test-file', 'w') { |f| f.puts "hello world" } 31 | File.read('/test-file') #=> "hello world\n" 32 | MemFs.deactivate! 33 | 34 | File.exists?('/test-file') #=> false 35 | 36 | # Or with the block syntax 37 | 38 | MemFs.activate do 39 | FileUtils.touch('/test-file', verbose: true, noop: true) 40 | File.exists?('/test-file') #=> true 41 | end 42 | 43 | File.exists?('/test-file') #=> false 44 | ``` 45 | 46 | ## Why you may prefer MemFs over FakeFS? 47 | 48 | While FakeFS is pretty cool it overrides classes like `FileUtils`. This kind of override is problematic when you rely on real behavior from this kind of tool. 49 | 50 | For instance, trying to test the following with FakeFS will not work, the `noop` option will be ignored: 51 | 52 | ``` ruby 53 | FileUtils.touch('somefile.txt', noop: true) 54 | ``` 55 | 56 | MemFs tries to be **compliant with the Ruby API** by overriding only the low level classes (C classes) like File, Dir or File::Stat leaving the stdlib classes untouched and still working, being less intrusive that way. 57 | 58 | Some stdlib classes may be overriden at some point if they don't use `File` or `Dir`, like `Pathname`, etc. 59 | 60 | Another key point is that MemFs **aims to implement every single method provided by Ruby classes** (when possible) and to behave and return **exactly** the same way as the original classes. 61 | 62 | ## Installation 63 | 64 | Add this line to your application's Gemfile: 65 | 66 | gem 'memfs' 67 | 68 | And then execute: 69 | 70 | $ bundle 71 | 72 | Or install it yourself as: 73 | 74 | $ gem install memfs 75 | 76 | ## Usage in tests 77 | 78 | ### Global activation 79 | 80 | Add the following to your `spec_helper.rb`: 81 | 82 | ``` ruby 83 | RSpec.configure do |config| 84 | config.before do 85 | MemFs.activate! 86 | end 87 | 88 | config.after do 89 | MemFs.deactivate! 90 | end 91 | end 92 | ``` 93 | 94 | All the spec will be sandboxed in MemFs. 95 | 96 | If you want to set it globally with flag activation, you can do the following in 97 | you `spec_helper.rb` file: 98 | 99 | ``` ruby 100 | Rspec.configure do |c| 101 | c.around(:each, memfs: true) do |example| 102 | MemFs.activate { example.run } 103 | end 104 | end 105 | ``` 106 | 107 | And then write your specs like this: 108 | 109 | ``` ruby 110 | it "creates a file", memfs: true do 111 | subject.create_file('test.rb') 112 | expect(File.exists?('test.rb')).to be true 113 | end 114 | ``` 115 | 116 | ### Local activation 117 | 118 | You can choose to activate MemFs only for a specific test: 119 | 120 | ``` ruby 121 | describe FileCreator do 122 | describe '.create_file' do 123 | it "creates a file" do 124 | MemFs.activate do 125 | subject.create_file('test.rb') 126 | expect(File.exists?('test.rb')).to be true 127 | end 128 | end 129 | end 130 | end 131 | ``` 132 | 133 | No real file will be created during the test. 134 | 135 | You can also use it for a specific `describe` block: 136 | 137 | ``` ruby 138 | describe FileCreator do 139 | before { MemFs.activate! } 140 | after { MemFs.deactivate! } 141 | 142 | describe '.create_file' do 143 | it "creates a file" do 144 | subject.create_file('test.rb') 145 | expect(File.exists?('test.rb')).to be true 146 | end 147 | end 148 | end 149 | ``` 150 | 151 | ### Utilities 152 | 153 | You can use `MemFs.touch` to quickly create a file and its parent directories: 154 | 155 | ``` ruby 156 | MemFs.activate do 157 | MemFs.touch('/path/to/some/file.rb') 158 | File.exist?('/path/to/some/file.rb') # => true 159 | end 160 | ``` 161 | 162 | ## Requirements 163 | 164 | * Ruby 2.0 or newer 165 | 166 | ## Known issues 167 | 168 | * MemFs doesn't implement IO so methods like `FileUtils.copy_stream` and `IO.write` are still the originals. 169 | * Similarly, MemFs doesn't implement Kernel, so don't use a naked `open()` call. This uses the `Kernel` class via `method_missing`, which MemFs will not intercept. 170 | * Pipes and Sockets are not handled for now. 171 | * ~`require "pp"` will raise a _superclass mismatch_ exception since MemFs::File does not inherit from IO. The best thing to do is to require pp _before_ MemFs.~ 172 | 173 | ## TODO 174 | 175 | * Implement missing methods from `File`, `Dir` and `Stat` 176 | 177 | ## Contributing 178 | 179 | 1. Fork it 180 | 2. Create your feature branch (`git checkout -b my-new-feature`) 181 | 3. Commit your changes (`git commit -am 'Add some feature'`) 182 | 4. Push to the branch (`git push origin my-new-feature`) 183 | 5. Create new Pull Request 184 | -------------------------------------------------------------------------------- /lib/memfs/io.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'memfs/filesystem_access' 5 | 6 | module MemFs 7 | class IO 8 | extend SingleForwardable 9 | include OriginalFile::Constants 10 | 11 | (OriginalIO.constants - OriginalFile::Constants.constants).each do |const_name| 12 | const_set(const_name, OriginalIO.const_get(const_name)) 13 | end 14 | 15 | def_delegators :original_io_class, 16 | :copy_stream 17 | 18 | def self.read(path, *args) 19 | options = args.last.is_a?(Hash) ? args.pop : {} 20 | options = { encoding: nil, mode: File::RDONLY, open_args: nil }.merge(options) 21 | open_args = options[:open_args] || [options[:mode], { encoding: options[:encoding] }] 22 | 23 | length, offset = args 24 | 25 | file = open(path, *open_args) 26 | file.seek(offset || 0) 27 | file.read(length) 28 | ensure 29 | file&.close 30 | end 31 | 32 | # rubocop:disable Metrics/MethodLength 33 | def self.write(path, string, offset = 0, open_args = nil) 34 | open_args ||= [File::WRONLY, { encoding: nil }] 35 | 36 | offset = 0 if offset.nil? 37 | unless offset.respond_to?(:to_int) 38 | fail TypeError, "no implicit conversion from #{offset.class}" 39 | end 40 | offset = offset.to_int 41 | 42 | if offset.positive? 43 | fail(NotImplementedError, 'MemFs::IO.write with offset not yet supported.') 44 | end 45 | 46 | file = open(path, *open_args) 47 | file.seek(offset) 48 | file.write(string) 49 | ensure 50 | file&.close 51 | end 52 | # rubocop:enable Metrics/MethodLength 53 | 54 | def self.original_io_class 55 | MemFs::OriginalIO 56 | end 57 | private_class_method :original_io_class 58 | 59 | attr_writer :autoclose, 60 | :close_on_exec 61 | 62 | def <<(object) 63 | fail IOError, 'not opened for writing' unless writable? 64 | 65 | content << object.to_s 66 | end 67 | 68 | def advise(advice_type, _offset = 0, _len = 0) 69 | advice_types = %i[dontneed noreuse normal random sequential willneed] 70 | 71 | return if advice_types.include?(advice_type) 72 | 73 | fail NotImplementedError, "Unsupported advice: #{advice_type.inspect}" 74 | end 75 | 76 | def autoclose? 77 | defined?(@autoclose) ? !!@autoclose : true 78 | end 79 | 80 | def binmode 81 | @binmode = true 82 | @external_encoding = Encoding::ASCII_8BIT 83 | self 84 | end 85 | 86 | def binmode? 87 | defined?(@binmode) ? @binmode : false 88 | end 89 | 90 | def close 91 | self.closed = true 92 | end 93 | 94 | def closed? 95 | closed 96 | end 97 | 98 | def close_on_exec? 99 | defined?(@close_on_exec) ? !!@close_on_exec : true 100 | end 101 | 102 | def eof? 103 | pos >= content.size 104 | end 105 | alias eof eof? 106 | 107 | def external_encoding 108 | if writable? 109 | @external_encoding 110 | else 111 | @external_encoding ||= Encoding.default_external 112 | end 113 | end 114 | 115 | def each(sep = $/, &block) 116 | return to_enum(__callee__, sep) unless block_given? 117 | fail IOError, 'not opened for reading' unless readable? 118 | content.each_line(sep) { |line| block.call(line) } 119 | self 120 | end 121 | 122 | def each_byte(&block) 123 | return to_enum(__callee__) unless block_given? 124 | fail IOError, 'not opened for reading' unless readable? 125 | content.each_byte { |byte| block.call(byte) } 126 | self 127 | end 128 | alias bytes each_byte 129 | 130 | def each_char(&block) 131 | return to_enum(__callee__) unless block_given? 132 | fail IOError, 'not opened for reading' unless readable? 133 | content.each_char { |char| block.call(char) } 134 | self 135 | end 136 | alias chars each_char 137 | 138 | def fileno 139 | entry.fileno 140 | end 141 | 142 | def pos 143 | entry.pos 144 | end 145 | 146 | def print(*objs) 147 | objs << $_ if objs.empty? 148 | self << objs.join($,) << $\.to_s 149 | nil 150 | end 151 | 152 | def printf(format_string, *objs) 153 | print format_string % objs 154 | end 155 | 156 | def puts(text) 157 | fail IOError, 'not opened for writing' unless writable? 158 | 159 | content.puts text 160 | end 161 | 162 | def read(length = nil, buffer = +'') 163 | fail(Errno::ENOENT, path) unless entry 164 | 165 | default = length ? nil : '' 166 | content.read(length, buffer) || default 167 | end 168 | 169 | def seek(amount, whence = ::IO::SEEK_SET) 170 | new_pos = 171 | case whence 172 | when ::IO::SEEK_CUR then entry.pos + amount 173 | when ::IO::SEEK_END then content.to_s.length + amount 174 | when ::IO::SEEK_SET then amount 175 | end 176 | 177 | fail Errno::EINVAL, path if new_pos.nil? || new_pos.negative? 178 | 179 | entry.pos = new_pos 180 | 0 181 | end 182 | 183 | def stat 184 | File.stat(path) 185 | end 186 | 187 | def write(string) 188 | fail(IOError, 'not opened for writing') unless writable? 189 | 190 | content.write(string.to_s) 191 | end 192 | 193 | private 194 | 195 | attr_accessor :closed, 196 | :entry, 197 | :opening_mode 198 | 199 | attr_reader :path 200 | 201 | def content 202 | entry.content 203 | end 204 | 205 | def create_file? 206 | (opening_mode & File::CREAT).nonzero? 207 | end 208 | 209 | def readable? 210 | (opening_mode & File::RDWR).nonzero? || 211 | (opening_mode | File::RDONLY).zero? 212 | end 213 | 214 | def str_to_mode_int(mode) 215 | return mode unless mode.is_a?(String) 216 | 217 | unless mode =~ /\A([rwa]\+?)([bt])?(:(bom|UTF-8|utf-8))?(\|.+)?\z/ 218 | fail ArgumentError, "invalid access mode #{mode}" 219 | end 220 | 221 | mode_str = $~[1] 222 | File::MODE_MAP[mode_str] 223 | end 224 | 225 | def truncate_file? 226 | (opening_mode & File::TRUNC).nonzero? 227 | end 228 | 229 | def writable? 230 | (opening_mode & File::WRONLY).nonzero? || 231 | (opening_mode & File::RDWR).nonzero? 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/memfs/file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'memfs/filesystem_access' 5 | require 'memfs/io' 6 | 7 | module MemFs 8 | class File < IO 9 | extend FilesystemAccess 10 | extend SingleForwardable 11 | 12 | include Enumerable 13 | include FilesystemAccess 14 | 15 | PATH_SEPARATOR = '/' 16 | ALT_SEPARATOR = nil 17 | 18 | MODE_MAP = { 19 | 'r' => RDONLY, 20 | 'r+' => RDWR, 21 | 'w' => CREAT | TRUNC | WRONLY, 22 | 'w+' => CREAT | TRUNC | RDWR, 23 | 'a' => CREAT | APPEND | WRONLY, 24 | 'a+' => CREAT | APPEND | RDWR 25 | }.freeze 26 | 27 | SEPARATOR = '/' 28 | SUCCESS = 0 29 | 30 | @umask = nil 31 | 32 | def_delegators :original_file_class, 33 | :basename, 34 | :dirname, 35 | :extname, 36 | :fnmatch, 37 | :join, 38 | :path, 39 | :split 40 | 41 | %i[ 42 | blockdev? 43 | chardev? 44 | directory? 45 | executable? 46 | executable_real? 47 | file? 48 | grpowned? 49 | owned? 50 | pipe? 51 | readable? 52 | readable_real? 53 | setgid? 54 | setuid? 55 | socket? 56 | sticky? 57 | writable? 58 | writable_real? 59 | zero? 60 | ].each do |query_method| 61 | # def directory?(path) 62 | # stat_query(path, :directory?) 63 | # end 64 | define_singleton_method(query_method) do |path| 65 | stat_query(path, query_method) 66 | end 67 | end 68 | 69 | class << self; alias empty? zero?; end 70 | 71 | %i[ 72 | world_readable? 73 | world_writable? 74 | ].each do |query_method| 75 | # def directory?(path) 76 | # stat_query(path, :directory?, false) 77 | # end 78 | define_singleton_method(query_method) do |path| 79 | stat_query(path, query_method, force_boolean: false) 80 | end 81 | end 82 | 83 | def self.absolute_path(path, dir_string = fs.pwd) 84 | original_file_class.absolute_path(path, dir_string) 85 | end 86 | 87 | def self.atime(path) 88 | stat(path).atime 89 | end 90 | 91 | def self.birthtime(path) 92 | stat(path).birthtime 93 | end 94 | 95 | def self.chmod(mode_int, *paths) 96 | paths.each do |path| 97 | fs.chmod mode_int, path 98 | end 99 | end 100 | 101 | def self.chown(uid, gid, *paths) 102 | paths.each do |path| 103 | fs.chown(uid, gid, path) 104 | end 105 | paths.size 106 | end 107 | 108 | def self.ctime(path) 109 | stat(path).ctime 110 | end 111 | 112 | def self.exists?(path) 113 | !!fs.find(path) 114 | end 115 | class << self; alias exist? exists?; end 116 | 117 | def self.expand_path(file_name, dir_string = fs.pwd) 118 | original_file_class.expand_path(file_name, dir_string) 119 | end 120 | 121 | def self.ftype(path) 122 | fs.find!(path) && lstat(path).ftype 123 | end 124 | 125 | class << self; alias fnmatch? fnmatch; end 126 | 127 | def self.identical?(path1, path2) 128 | fs.find!(path1).dereferenced.equal? fs.find!(path2).dereferenced 129 | rescue Errno::ENOENT 130 | false 131 | end 132 | 133 | def self.lchmod(mode_int, *file_names) 134 | file_names.each do |file_name| 135 | fs.chmod mode_int, file_name 136 | end 137 | end 138 | 139 | def self.lchown(uid, gid, *paths) 140 | chown uid, gid, *paths 141 | end 142 | 143 | def self.link(old_name, new_name) 144 | fs.link old_name, new_name 145 | SUCCESS 146 | end 147 | 148 | def self.lstat(path) 149 | Stat.new(path) 150 | end 151 | 152 | def self.mtime(path) 153 | stat(path).mtime 154 | end 155 | 156 | def self.open(filename, mode = RDONLY, *perm_and_opt) 157 | file = new(filename, mode, *perm_and_opt) 158 | 159 | if block_given? 160 | yield file 161 | else 162 | file 163 | end 164 | ensure 165 | file.close if file && block_given? 166 | end 167 | 168 | def self.readlink(path) 169 | fs.find!(path).target 170 | end 171 | 172 | def self.realdirpath(path, dir_string = fs.pwd) 173 | loose_dereference_path(absolute_path(path, dir_string)) 174 | end 175 | 176 | def self.realpath(path, dir_string = fs.pwd) 177 | dereference_path(absolute_path(path, dir_string)) 178 | end 179 | 180 | def self.rename(old_name, new_name) 181 | fs.rename(old_name, new_name) 182 | SUCCESS 183 | end 184 | 185 | def self.reset! 186 | @umask = original_file_class.umask 187 | end 188 | 189 | def self.size(path) 190 | fs.find!(path).size 191 | end 192 | 193 | def self.size?(path) 194 | file = fs.find(path) 195 | size = file&.size.to_i 196 | 197 | size.positive? ? size : false 198 | end 199 | 200 | def self.stat(path) 201 | Stat.new(path, dereference: true) 202 | end 203 | 204 | def self.symlink(old_name, new_name) 205 | fs.symlink old_name, new_name 206 | SUCCESS 207 | end 208 | 209 | def self.symlink?(path) 210 | lstat_query(path, :symlink?) 211 | end 212 | 213 | def self.truncate(path, length) 214 | fs.find!(path).content.truncate(length) 215 | SUCCESS 216 | end 217 | 218 | def self.umask(integer = nil) 219 | old_value = @umask || original_file_class.umask 220 | 221 | @umask = integer if integer 222 | 223 | old_value 224 | end 225 | 226 | def self.unlink(*paths) 227 | paths.each { |path| fs.unlink(path) } 228 | paths.size 229 | end 230 | class << self; alias delete unlink; end 231 | 232 | def self.utime(atime, mtime, *file_names) 233 | file_names.each do |file_name| 234 | fs.find!(file_name).atime = atime 235 | fs.find!(file_name).mtime = mtime 236 | end 237 | file_names.size 238 | end 239 | 240 | attr_reader :path 241 | 242 | # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 243 | def initialize(filename, mode = File::RDONLY, *perm_and_or_opt) 244 | super() 245 | 246 | opt = perm_and_or_opt.last.is_a?(Hash) ? perm_and_or_opt.pop : {} 247 | perm_and_or_opt.shift 248 | 249 | fail ArgumentError, 'wrong number of arguments (4 for 1..3)' if perm_and_or_opt.any? 250 | 251 | @path = filename 252 | @external_encoding = 253 | opt[:external_encoding] && Encoding.find(opt[:external_encoding]) 254 | 255 | self.closed = false 256 | self.opening_mode = str_to_mode_int(mode) 257 | 258 | fs.touch(filename) if create_file? 259 | 260 | self.entry = fs.find!(filename) 261 | # FIXME: this is an ugly way to ensure a symlink has a target 262 | entry.dereferenced 263 | 264 | entry.pos = 0 if entry.respond_to?(:pos=) 265 | entry.content.clear if truncate_file? 266 | end 267 | # rubocop:enable Metrics/AbcSize, Metrics/MethodLength 268 | 269 | def atime 270 | File.atime(path) 271 | end 272 | 273 | def birthtime 274 | File.birthtime(path) 275 | end 276 | 277 | def chmod(mode_int) 278 | fs.chmod(mode_int, path) 279 | SUCCESS 280 | end 281 | 282 | def chown(uid, gid = nil) 283 | fs.chown(uid, gid, path) 284 | SUCCESS 285 | end 286 | 287 | def ctime 288 | File.ctime(path) 289 | end 290 | 291 | def flock(*) 292 | SUCCESS 293 | end 294 | 295 | def mtime 296 | File.mtime(path) 297 | end 298 | 299 | def lstat 300 | File.lstat(path) 301 | end 302 | 303 | def size 304 | entry.size 305 | end 306 | 307 | def truncate(integer) 308 | File.truncate(path, integer) 309 | end 310 | 311 | def self.dereference_name(path) 312 | entry = fs.find(path) 313 | entry ? entry.dereferenced_name : basename(path) 314 | end 315 | private_class_method :dereference_name 316 | 317 | def self.dereference_dir_path(path) 318 | dereference_path(dirname(path)) 319 | end 320 | private_class_method :dereference_dir_path 321 | 322 | def self.dereference_path(path) 323 | fs.find!(path).dereferenced_path 324 | end 325 | private_class_method :dereference_path 326 | 327 | def self.loose_dereference_path(path) 328 | join(dereference_dir_path(path), dereference_name(path)) 329 | end 330 | private_class_method :loose_dereference_path 331 | 332 | def self.original_file_class 333 | MemFs::OriginalFile 334 | end 335 | private_class_method :original_file_class 336 | 337 | def self.stat_query(path, query, force_boolean: true) 338 | response = fs.find(path) && stat(path).public_send(query) 339 | force_boolean ? !!response : response 340 | end 341 | private_class_method :stat_query 342 | 343 | def self.lstat_query(path, query) 344 | response = fs.find(path) && lstat(path).public_send(query) 345 | !!response 346 | end 347 | private_class_method :lstat_query 348 | end 349 | end 350 | -------------------------------------------------------------------------------- /spec/memfs/file_system_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MemFs 4 | ::RSpec.describe FileSystem do 5 | subject { _fs } 6 | 7 | before :each do 8 | subject.mkdir '/test-dir' 9 | end 10 | 11 | describe '#chdir' do 12 | it 'changes the current working directory' do 13 | subject.chdir '/test-dir' 14 | expect(subject.getwd).to eq('/test-dir') 15 | end 16 | 17 | it 'raises an error if directory does not exist' do 18 | expect { subject.chdir('/nowhere') }.to raise_error(Errno::ENOENT) 19 | end 20 | 21 | it 'raises an error if the destination is not a directory' do 22 | subject.touch('/test-file') 23 | expect { subject.chdir('/test-file') }.to raise_error(Errno::ENOTDIR) 24 | end 25 | 26 | context 'when a block is given' do 27 | it 'changes current working directory for the block' do 28 | location = nil 29 | subject.chdir '/test-dir' do 30 | location = subject.getwd 31 | end 32 | expect(location).to eq('/test-dir') 33 | end 34 | 35 | it 'gets back to previous directory once the block is finished' do 36 | subject.chdir '/' 37 | expect { 38 | subject.chdir('/test-dir') {} 39 | }.to_not change { subject.getwd } 40 | end 41 | end 42 | 43 | context 'when the destination is a symlink' do 44 | it 'sets current directory as the last link chain target' do 45 | subject.symlink('/test-dir', '/test-link') 46 | subject.chdir('/test-link') 47 | expect(subject.getwd).to eq('/test-dir') 48 | end 49 | end 50 | end 51 | 52 | describe '#chmod' do 53 | it 'changes permission bits on the named file' do 54 | subject.touch('/some-file') 55 | subject.chmod(0o777, '/some-file') 56 | expect(subject.find!('/some-file').mode).to be(0o100777) 57 | end 58 | 59 | context 'when the named file is a symlink' do 60 | it 'changes the permission bits on the symlink itself' do 61 | subject.touch('/some-file') 62 | subject.symlink('/some-file', '/some-link') 63 | subject.chmod(0o777, '/some-link') 64 | expect(subject.find!('/some-link').mode).to be(0o100777) 65 | end 66 | end 67 | end 68 | 69 | describe '#chown' do 70 | before :each do 71 | subject.touch '/test-file' 72 | end 73 | 74 | it 'changes the owner of the named file to the given numeric owner id' do 75 | subject.chown(42, nil, '/test-file') 76 | expect(subject.find!('/test-file').uid).to be(42) 77 | end 78 | 79 | it 'changes the group of the named file to the given numeric group id' do 80 | subject.chown(nil, 42, '/test-file') 81 | expect(subject.find!('/test-file').gid).to be(42) 82 | end 83 | 84 | it 'ignores nil user id' do 85 | expect { 86 | subject.chown(nil, 42, '/test-file') 87 | }.to_not change { subject.find!('/test-file').uid } 88 | end 89 | 90 | it 'ignores nil group id' do 91 | expect { 92 | subject.chown(42, nil, '/test-file') 93 | }.to_not change { subject.find!('/test-file').gid } 94 | end 95 | 96 | it 'ignores -1 user id' do 97 | expect { 98 | subject.chown(-1, 42, '/test-file') 99 | }.to_not change { subject.find!('/test-file').uid } 100 | end 101 | 102 | it 'ignores -1 group id' do 103 | expect { 104 | subject.chown(42, -1, '/test-file') 105 | }.to_not change { subject.find!('/test-file').gid } 106 | end 107 | 108 | context 'when the named entry is a symlink' do 109 | before :each do 110 | subject.symlink '/test-file', '/test-link' 111 | end 112 | 113 | it 'changes the owner on the last target of the link chain' do 114 | subject.chown(42, nil, '/test-link') 115 | expect(subject.find!('/test-file').uid).to be(42) 116 | end 117 | 118 | it 'changes the group on the last target of the link chain' do 119 | subject.chown(nil, 42, '/test-link') 120 | expect(subject.find!('/test-file').gid).to be(42) 121 | end 122 | 123 | it "doesn't change the owner of the symlink" do 124 | subject.chown(42, nil, '/test-link') 125 | expect(subject.find!('/test-link').uid).not_to eq(42) 126 | end 127 | 128 | it "doesn't change the group of the symlink" do 129 | subject.chown(nil, 42, '/test-link') 130 | expect(subject.find!('/test-link').gid).not_to eq(42) 131 | end 132 | end 133 | end 134 | 135 | describe '#clear!' do 136 | it 'clear the registred entries' do 137 | subject.clear! 138 | expect(subject.root.entry_names).to eq(%w[. .. tmp]) 139 | end 140 | 141 | it 'sets the current directory to /' do 142 | subject.clear! 143 | expect(subject.getwd).to eq('/') 144 | end 145 | end 146 | 147 | describe '#entries' do 148 | it 'returns an array containing all of the filenames in the given directory' do 149 | %w[/test-dir/new-dir /test-dir/new-dir2].each { |dir| subject.mkdir dir } 150 | subject.touch '/test-dir/test-file', '/test-dir/test-file2' 151 | expect(subject.entries('/test-dir')).to eq(%w[. .. new-dir new-dir2 test-file test-file2]) 152 | end 153 | end 154 | 155 | describe '#find' do 156 | context 'when the entry for the given path exists' do 157 | it 'returns the entry' do 158 | entry = subject.find('/test-dir') 159 | expect(entry).not_to be_nil 160 | end 161 | end 162 | 163 | context 'when there is no entry for the given path' do 164 | it 'returns nil' do 165 | entry = subject.find('/no-file') 166 | expect(entry).to be_nil 167 | end 168 | end 169 | 170 | context 'when a part of the given path is a symlink' do 171 | before :each do 172 | subject.symlink('/test-dir', '/test-dir-link') 173 | subject.symlink('/no-dir', '/test-no-link') 174 | subject.touch('/test-dir/test-file') 175 | end 176 | 177 | context "and the symlink's target exists" do 178 | it 'returns the entry' do 179 | entry = subject.find('/test-dir-link/test-file') 180 | expect(entry).not_to be_nil 181 | end 182 | end 183 | 184 | context "and the symlink's target does not exist" do 185 | it 'returns nil' do 186 | entry = subject.find('/test-no-link/test-file') 187 | expect(entry).to be_nil 188 | end 189 | end 190 | end 191 | end 192 | 193 | describe '#find!' do 194 | context 'when the entry for the given path exists' do 195 | it 'returns the entry' do 196 | entry = subject.find!('/test-dir') 197 | expect(entry).not_to be_nil 198 | end 199 | end 200 | 201 | context 'when there is no entry for the given path' do 202 | it 'raises an exception' do 203 | expect { subject.find!('/no-file') }.to raise_error Errno::ENOENT 204 | end 205 | end 206 | 207 | context 'when a part of the given path is a symlink' do 208 | before :each do 209 | _fs.symlink('/test-dir', '/test-dir-link') 210 | _fs.touch('/test-dir/test-file') 211 | end 212 | 213 | context "and the symlink's target exists" do 214 | it 'returns the entry' do 215 | entry = subject.find!('/test-dir-link/test-file') 216 | expect(entry).not_to be_nil 217 | end 218 | end 219 | 220 | context "and the symlink's target does not exist" do 221 | it 'raises an exception' do 222 | expect { 223 | subject.find!('/test-no-link/test-file') 224 | }.to raise_error Errno::ENOENT 225 | end 226 | end 227 | end 228 | end 229 | 230 | describe '#find_directory!' do 231 | it 'returns the named directory' do 232 | expect(subject.find_directory!('/test-dir')).to be_a(Fake::Directory) 233 | end 234 | 235 | it 'raises an error if the named entry is not a directory' do 236 | subject.touch '/test-file' 237 | expect { subject.find_directory!('/test-file') }.to raise_error(Errno::ENOTDIR) 238 | end 239 | end 240 | 241 | describe '#find_parent!' do 242 | it 'returns the parent directory of the named entry' do 243 | expect(subject.find_parent!('/test-dir/test-file')).to be_a(Fake::Directory) 244 | end 245 | 246 | it 'raises an error if the parent directory does not exist' do 247 | expect { subject.find_parent!('/no-dir/test-file') }.to raise_error(Errno::ENOENT) 248 | end 249 | 250 | it 'raises an error if the parent is not a directory' do 251 | subject.touch('/test-file') 252 | expect { subject.find_parent!('/test-file/test') }.to raise_error(Errno::ENOTDIR) 253 | end 254 | end 255 | 256 | describe '#getwd' do 257 | it 'returns the current working directory' do 258 | subject.chdir '/test-dir' 259 | expect(subject.getwd).to eq('/test-dir') 260 | end 261 | end 262 | 263 | describe '#link' do 264 | before :each do 265 | subject.touch('/some-file') 266 | end 267 | 268 | it 'creates a hard link +dest+ that points to +src+' do 269 | subject.link('/some-file', '/some-link') 270 | expect(subject.find!('/some-link').content).to be(subject.find!('/some-file').content) 271 | end 272 | 273 | it 'does not create a symbolic link' do 274 | subject.link('/some-file', '/some-link') 275 | expect(subject.find!('/some-link')).not_to be_a(Fake::Symlink) 276 | end 277 | 278 | context 'when +new_name+ already exists' do 279 | it 'raises an exception' do 280 | subject.touch('/some-link') 281 | expect { subject.link('/some-file', '/some-link') }.to raise_error(SystemCallError) 282 | end 283 | end 284 | end 285 | 286 | describe '#mkdir' do 287 | it 'creates a directory' do 288 | subject.mkdir '/new-dir' 289 | expect(subject.find!('/new-dir')).to be_a(Fake::Directory) 290 | end 291 | 292 | it 'sets directory permissions to default 0o777' do 293 | subject.mkdir '/new-dir' 294 | expect(subject.find!('/new-dir').mode).to eq(0o100777) 295 | end 296 | 297 | context 'when permissions are specified' do 298 | it 'sets directory permission to specified value' do 299 | subject.mkdir '/new-dir', 0o644 300 | expect(subject.find!('/new-dir').mode).to eq(0o100644) 301 | end 302 | end 303 | 304 | context 'when a relative path is given' do 305 | it 'creates a directory in current directory' do 306 | subject.chdir '/test-dir' 307 | subject.mkdir 'new-dir' 308 | expect(subject.find!('/test-dir/new-dir')).to be_a(Fake::Directory) 309 | end 310 | end 311 | 312 | context 'when the directory already exists' do 313 | it 'raises an exception' do 314 | expect { subject.mkdir('/') }.to raise_error(Errno::EEXIST) 315 | end 316 | end 317 | end 318 | 319 | describe '#new' do 320 | it 'creates the root directory' do 321 | expect(subject.find!('/')).to be(subject.root) 322 | end 323 | end 324 | 325 | describe '#paths' do 326 | before do 327 | subject.mkdir('/test-dir/subdir') 328 | subject.touch('/test-dir/subdir/file1', '/test-dir/subdir/file2') 329 | end 330 | 331 | it 'returns the list of all the existing paths' do 332 | expect(subject.paths).to eq \ 333 | %w[/ /tmp /test-dir /test-dir/subdir /test-dir/subdir/file1 /test-dir/subdir/file2] 334 | end 335 | end 336 | 337 | describe '#pwd' do 338 | it_behaves_like 'aliased method', :pwd, :getwd 339 | end 340 | 341 | describe '#rename' do 342 | it 'renames the given file to the new name' do 343 | subject.touch('/test-file') 344 | subject.rename('/test-file', '/test-file2') 345 | expect(subject.find('/test-file2')).not_to be_nil 346 | end 347 | 348 | it 'removes the old file' do 349 | subject.touch('/test-file') 350 | subject.rename('/test-file', '/test-file2') 351 | expect(subject.find('/test-file')).to be_nil 352 | end 353 | 354 | it 'can move a file in another directory' do 355 | subject.touch('/test-file') 356 | subject.rename('/test-file', '/test-dir/test-file') 357 | expect(subject.find('/test-dir/test-file')).not_to be_nil 358 | end 359 | end 360 | 361 | describe '#rmdir' do 362 | it 'removes the given directory' do 363 | subject.rmdir('/test-dir') 364 | expect(subject.find('/test-dir')).to be_nil 365 | end 366 | 367 | context 'when the directory is not empty' do 368 | it 'raises an exception' do 369 | subject.mkdir('/test-dir/test-sub-dir') 370 | expect { subject.rmdir('/test-dir') }.to raise_error(Errno::ENOTEMPTY) 371 | end 372 | end 373 | end 374 | 375 | describe '#symlink' do 376 | it 'creates a symbolic link' do 377 | subject.touch('/some-file') 378 | subject.symlink('/some-file', '/some-link') 379 | expect(subject.find!('/some-link')).to be_a(Fake::Symlink) 380 | end 381 | 382 | context 'when +new_name+ already exists' do 383 | it 'raises an exception' do 384 | subject.touch('/some-file') 385 | subject.touch('/some-file2') 386 | expect { subject.symlink('/some-file', '/some-file2') }.to raise_error(Errno::EEXIST) 387 | end 388 | end 389 | end 390 | 391 | describe '#touch' do 392 | it 'creates a regular file' do 393 | subject.touch '/some-file' 394 | expect(subject.find!('/some-file')).to be_a(Fake::File) 395 | end 396 | 397 | it 'creates a regular file for each named filed' do 398 | subject.touch '/some-file', '/some-file2' 399 | expect(subject.find!('/some-file2')).to be_a(Fake::File) 400 | end 401 | 402 | it "creates an entry only if it doesn't exist" do 403 | subject.touch '/some-file' 404 | expect(MemFs::Fake::File).not_to receive(:new) 405 | subject.touch '/some-file' 406 | end 407 | 408 | context 'when the named file already exists' do 409 | let(:time) { Time.now - 5000 } 410 | before :each do 411 | subject.touch '/some-file' 412 | file = subject.find!('/some-file') 413 | file.atime = file.mtime = time 414 | end 415 | 416 | it 'sets the access time of the touched file' do 417 | subject.touch '/some-file' 418 | expect(subject.find!('/some-file').atime).not_to eq(time) 419 | end 420 | 421 | it 'sets the modification time of the touched file' do 422 | subject.touch '/some-file' 423 | expect(subject.find!('/some-file').atime).not_to eq(time) 424 | end 425 | end 426 | end 427 | 428 | describe '#unlink' do 429 | it 'deletes the named file' do 430 | subject.touch('/some-file') 431 | subject.unlink('/some-file') 432 | expect(subject.find('/some-file')).to be_nil 433 | end 434 | 435 | context 'when the entry is a directory' do 436 | it 'raises an exception' do 437 | expect { subject.unlink('/test-dir') }.to raise_error Errno::EPERM 438 | end 439 | end 440 | end 441 | end 442 | end 443 | -------------------------------------------------------------------------------- /spec/memfs/dir_spec.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | module MemFs 4 | ::RSpec.describe Dir do 5 | subject { described_class.new('/test') } 6 | 7 | before { described_class.mkdir '/test' } 8 | 9 | it 'is Enumerable' do 10 | expect(subject).to be_an(Enumerable) 11 | end 12 | 13 | describe '[]' do 14 | context 'when a string is given' do 15 | it 'acts like calling glob' do 16 | expect(described_class['/*']).to eq %w[/tmp /test] 17 | end 18 | end 19 | 20 | context 'when a list of strings is given' do 21 | it 'acts like calling glob' do 22 | expect(described_class['/tm*', '/te*']).to eq %w[/tmp /test] 23 | end 24 | end 25 | end 26 | 27 | describe '.chdir' do 28 | it 'changes the current working directory' do 29 | described_class.chdir '/test' 30 | expect(described_class.getwd).to eq('/test') 31 | end 32 | 33 | it 'returns zero' do 34 | expect(described_class.chdir('/test')).to be_zero 35 | end 36 | 37 | it 'raises an error when the folder does not exist' do 38 | expect { described_class.chdir('/nowhere') }.to raise_error(Errno::ENOENT) 39 | end 40 | 41 | context 'when a block is given' do 42 | it 'changes current working directory for the block' do 43 | described_class.chdir '/test' do 44 | expect(described_class.pwd).to eq('/test') 45 | end 46 | end 47 | 48 | it 'gets back to previous directory once the block is finished' do 49 | described_class.chdir '/' 50 | expect { 51 | described_class.chdir('/test') {} 52 | }.to_not change { described_class.pwd } 53 | end 54 | end 55 | end 56 | 57 | if MemFs.ruby_version_gte?('2.6') 58 | describe '.children' do 59 | it 'returns an array containing all of the filenames except for "." and ".." in this directory.' do 60 | %w[/test/dir1 /test/dir2].each { |dir| described_class.mkdir dir } 61 | _fs.touch '/test/file1', '/test/file2' 62 | expect(described_class.children('/test')).to eq(%w[dir1 dir2 file1 file2]) 63 | end 64 | end 65 | else 66 | describe '.children' do 67 | it 'raises an error' do 68 | expect { described_class.children('/test') }.to raise_error(NoMethodError) 69 | end 70 | end 71 | end 72 | 73 | describe '.chroot' do 74 | before { allow(Process).to receive_messages(uid: 0) } 75 | 76 | it "changes the process's idea of the file system root" do 77 | described_class.mkdir('/test/subdir') 78 | described_class.chroot('/test') 79 | 80 | expect(File.exist?('/subdir')).to be true 81 | end 82 | 83 | it 'returns zero' do 84 | expect(described_class.chroot('/test')).to eq 0 85 | end 86 | 87 | context 'when the given path is a file' do 88 | before { _fs.touch('/test/test-file') } 89 | 90 | it 'raises an exception' do 91 | expect { described_class.chroot('/test/test-file') }.to raise_error(Errno::ENOTDIR) 92 | end 93 | end 94 | 95 | context "when the given path doesn't exist" do 96 | it 'raises an exception' do 97 | expect { described_class.chroot('/no-dir') }.to raise_error(Errno::ENOENT) 98 | end 99 | end 100 | 101 | context 'when the user is not root' do 102 | before { allow(Process).to receive_messages(uid: 42) } 103 | 104 | it 'raises an exception' do 105 | expect { described_class.chroot('/no-dir') }.to raise_error(Errno::EPERM) 106 | end 107 | end 108 | end 109 | 110 | describe '.delete' do 111 | subject { described_class } 112 | it_behaves_like 'aliased method', :delete, :rmdir 113 | end 114 | 115 | describe '.empty?' do 116 | context 'when the given directory is empty' do 117 | it 'returns true' do 118 | expect(described_class.empty?('/test')).to be true 119 | end 120 | end 121 | 122 | context 'when the given directory is non-empty' do 123 | before { Dir.mkdir('/test/sub-dir') } 124 | 125 | it 'returns false' do 126 | expect(described_class.empty?('/test')).to be false 127 | end 128 | end 129 | 130 | context 'when the given directory does not exist' do 131 | it 'raises an exception' do 132 | expect { 133 | described_class.empty?('/nothing') 134 | }.to raise_exception(Errno::ENOENT) 135 | end 136 | end 137 | 138 | context 'when the given entry is not a directory' do 139 | before { _fs.touch '/test/file1' } 140 | 141 | it 'returns false' do 142 | expect(described_class.empty?('/test/file1')).to be false 143 | end 144 | end 145 | end 146 | 147 | describe '.entries' do 148 | it 'returns an array containing all of the filenames in the given directory' do 149 | %w[/test/dir1 /test/dir2].each { |dir| described_class.mkdir dir } 150 | _fs.touch '/test/file1', '/test/file2' 151 | expect(described_class.entries('/test')).to eq(%w[. .. dir1 dir2 file1 file2]) 152 | end 153 | end 154 | 155 | describe '.exist?' do 156 | subject { described_class } 157 | it_behaves_like 'aliased method', :exist?, :exists? 158 | end 159 | 160 | describe '.exists?' do 161 | it 'returns true if the given +path+ exists and is a directory' do 162 | described_class.mkdir('/test-dir') 163 | expect(described_class.exists?('/test-dir')).to be true 164 | end 165 | 166 | it 'returns false if the given +path+ does not exist' do 167 | expect(described_class.exists?('/test-dir')).to be false 168 | end 169 | 170 | it 'returns false if the given +path+ is not a directory' do 171 | _fs.touch('/test-file') 172 | expect(described_class.exists?('/test-file')).to be false 173 | end 174 | end 175 | 176 | describe '.foreach' do 177 | before :each do 178 | _fs.touch('/test/test-file', '/test/test-file2') 179 | end 180 | 181 | context 'when a block is given' do 182 | it 'calls the block once for each entry in the named directory' do 183 | expect { |blk| 184 | described_class.foreach('/test', &blk) 185 | }.to yield_control.exactly(4).times 186 | end 187 | 188 | it 'passes each entry as a parameter to the block' do 189 | expect { |blk| 190 | described_class.foreach('/test', &blk) 191 | }.to yield_successive_args('.', '..', 'test-file', 'test-file2') 192 | end 193 | 194 | context "and the directory doesn't exist" do 195 | it 'raises an exception' do 196 | expect { 197 | described_class.foreach('/no-dir') {} 198 | }.to raise_error Errno::ENOENT 199 | end 200 | end 201 | 202 | context 'and the given path is not a directory' do 203 | it 'raises an exception' do 204 | expect { 205 | described_class.foreach('/test/test-file') {} 206 | }.to raise_error Errno::ENOTDIR 207 | end 208 | end 209 | end 210 | 211 | context 'when no block is given' do 212 | it 'returns an enumerator' do 213 | list = described_class.foreach('/test-dir') 214 | expect(list).to be_an(Enumerator) 215 | end 216 | 217 | context "and the directory doesn't exist" do 218 | it 'returns an enumerator' do 219 | list = described_class.foreach('/no-dir') 220 | expect(list).to be_an(Enumerator) 221 | end 222 | end 223 | 224 | context 'and the given path is not a directory' do 225 | it 'returns an enumerator' do 226 | list = described_class.foreach('/test-dir/test-file') 227 | expect(list).to be_an(Enumerator) 228 | end 229 | end 230 | end 231 | end 232 | 233 | describe '.getwd' do 234 | it 'returns the path to the current working directory' do 235 | expect(described_class.getwd).to eq(FileSystem.instance.getwd) 236 | end 237 | end 238 | 239 | describe '.glob' do 240 | before do 241 | _fs.clear! 242 | 3.times do |dirnum| 243 | _fs.mkdir "/test#{dirnum}" 244 | _fs.mkdir "/test#{dirnum}/subdir" 245 | 3.times do |filenum| 246 | _fs.touch "/test#{dirnum}/subdir/file#{filenum}" 247 | end 248 | end 249 | end 250 | 251 | shared_examples 'returning matching filenames' do |pattern, filenames| 252 | it "with #{pattern}" do 253 | expect(described_class.glob(pattern)).to eq filenames 254 | end 255 | end 256 | 257 | it_behaves_like 'returning matching filenames', '/', %w[/] 258 | it_behaves_like 'returning matching filenames', '/test0', %w[/test0] 259 | it_behaves_like 'returning matching filenames', '/*', %w[/tmp /test0 /test1 /test2] 260 | it_behaves_like 'returning matching filenames', '/test*', %w[/test0 /test1 /test2] 261 | it_behaves_like 'returning matching filenames', '/*0', %w[/test0] 262 | it_behaves_like 'returning matching filenames', '/*es*', %w[/test0 /test1 /test2] 263 | it_behaves_like 'returning matching filenames', '/**/file0', %w[/test0/subdir/file0 /test1/subdir/file0 /test2/subdir/file0] 264 | it_behaves_like 'returning matching filenames', '/test?', %w[/test0 /test1 /test2] 265 | it_behaves_like 'returning matching filenames', '/test[01]', %w[/test0 /test1] 266 | it_behaves_like 'returning matching filenames', '/test[^2]', %w[/test0 /test1] 267 | it_behaves_like 'returning matching filenames', Pathname.new('/'), %w[/] 268 | it_behaves_like 'returning matching filenames', Pathname.new('/*'), %w[/tmp /test0 /test1 /test2] 269 | 270 | if defined?(File::FNM_EXTGLOB) 271 | it_behaves_like 'returning matching filenames', '/test{1,2}', %w[/test1 /test2] 272 | end 273 | 274 | context 'when a flag is given' do 275 | it 'uses it to compare filenames' do 276 | expect(described_class.glob('/TEST*', File::FNM_CASEFOLD)).to eq \ 277 | %w[/test0 /test1 /test2] 278 | end 279 | end 280 | 281 | context 'when a block is given' do 282 | it 'calls the block with every matching filenames' do 283 | expect { |blk| described_class.glob('/test*', &blk) }.to \ 284 | yield_successive_args('/test0', '/test1', '/test2') 285 | end 286 | 287 | it 'returns nil' do 288 | expect(described_class.glob('/*') {}).to be nil 289 | end 290 | end 291 | 292 | context 'when pattern is an array of patterns' do 293 | it 'returns the list of files matching any pattern' do 294 | expect(described_class.glob(['/*0', '/*1'])).to eq %w[/test0 /test1] 295 | end 296 | end 297 | end 298 | 299 | describe '.home' do 300 | it 'returns the home directory of the current user' do 301 | expect(described_class.home).to eq ENV['HOME'] 302 | end 303 | 304 | context 'when a username is given' do 305 | it 'returns the home directory of the given user' do 306 | home_dir = described_class.home(ENV['USER']) 307 | expect(home_dir).to eq ENV['HOME'] 308 | end 309 | end 310 | end 311 | 312 | describe '.mkdir' do 313 | it 'creates a directory' do 314 | described_class.mkdir '/new-folder' 315 | expect(File.directory?('/new-folder')).to be true 316 | end 317 | 318 | it 'sets directory permissions to default 0o777' do 319 | described_class.mkdir '/new-folder' 320 | expect(File.stat('/new-folder').mode).to eq(0o100777) 321 | end 322 | 323 | context 'when permissions are specified' do 324 | it 'sets directory permissions to specified value' do 325 | described_class.mkdir '/new-folder', 0o644 326 | expect(File.stat('/new-folder').mode).to eq(0o100644) 327 | end 328 | end 329 | 330 | context 'when the directory already exist' do 331 | it 'raises an exception' do 332 | expect { described_class.mkdir('/') }.to raise_error(Errno::EEXIST) 333 | end 334 | end 335 | end 336 | 337 | describe '.open' do 338 | context 'when no block is given' do 339 | it 'returns the opened directory' do 340 | expect(described_class.open('/test')).to be_a(Dir) 341 | end 342 | end 343 | 344 | context 'when a block is given' do 345 | it 'calls the block with the opened directory as argument' do 346 | expect { |blk| described_class.open('/test', &blk) }.to yield_with_args(Dir) 347 | end 348 | 349 | it 'returns nil' do 350 | expect(described_class.open('/test') {}).to be_nil 351 | end 352 | 353 | it 'ensures the directory is closed' do 354 | dir = nil 355 | described_class.open('/test') { |d| dir = d } 356 | expect { dir.close }.to raise_error(IOError) 357 | end 358 | end 359 | 360 | context "when the given directory doesn't exist" do 361 | it 'raises an exception' do 362 | expect { 363 | described_class.open('/no-dir') 364 | }.to raise_error Errno::ENOENT 365 | end 366 | end 367 | 368 | context 'when the given path is not a directory' do 369 | before { _fs.touch('/test/test-file') } 370 | 371 | it 'raises an exception' do 372 | expect { 373 | described_class.open('/test/test-file') 374 | }.to raise_error Errno::ENOTDIR 375 | end 376 | end 377 | end 378 | 379 | describe '.new' do 380 | context "when the given directory doesn't exist" do 381 | it 'raises an exception' do 382 | expect { 383 | described_class.new('/no-dir') 384 | }.to raise_error Errno::ENOENT 385 | end 386 | end 387 | 388 | context 'when the given path is not a directory' do 389 | before { _fs.touch('/test/test-file') } 390 | 391 | it 'raises an exception' do 392 | expect { 393 | described_class.new('/test/test-file') 394 | }.to raise_error Errno::ENOTDIR 395 | end 396 | end 397 | end 398 | 399 | describe '.pwd' do 400 | subject { described_class } 401 | it_behaves_like 'aliased method', :pwd, :getwd 402 | end 403 | 404 | describe '.rmdir' do 405 | it 'deletes the named directory' do 406 | described_class.mkdir('/test-dir') 407 | described_class.rmdir('/test-dir') 408 | expect(described_class.exists?('/test-dir')).to be false 409 | end 410 | 411 | context 'when the directory is not empty' do 412 | it 'raises an exception' do 413 | described_class.mkdir('/test-dir') 414 | described_class.mkdir('/test-dir/test-sub-dir') 415 | expect { described_class.rmdir('/test-dir') }.to raise_error(Errno::ENOTEMPTY) 416 | end 417 | end 418 | end 419 | 420 | describe '.tmpdir' do 421 | it 'returns /tmp' do 422 | expect(described_class.tmpdir).to eq '/tmp' 423 | end 424 | end 425 | 426 | describe '.unlink' do 427 | subject { described_class } 428 | it_behaves_like 'aliased method', :unlink, :rmdir 429 | end 430 | 431 | describe '#close' do 432 | it 'closes the directory' do 433 | dir = described_class.open('/test') 434 | dir.close 435 | expect { dir.close }.to raise_error(IOError) 436 | end 437 | end 438 | 439 | describe '#each' do 440 | before { _fs.touch('/test/test-file', '/test/test-file2') } 441 | 442 | it 'calls the block once for each entry in this directory' do 443 | expect { |blk| subject.each(&blk) }.to yield_control.exactly(4).times 444 | end 445 | 446 | it 'passes the filename of each entry as a parameter to the block' do 447 | expect { |blk| 448 | subject.each(&blk) 449 | }.to yield_successive_args('.', '..', 'test-file', 'test-file2') 450 | end 451 | 452 | context 'when no block is given' do 453 | it 'returns an enumerator' do 454 | expect(subject.each).to be_an(Enumerator) 455 | end 456 | end 457 | end 458 | 459 | describe '#fileno' do 460 | it 'raises an exception' do 461 | expect { subject.fileno }.to raise_exception(NotImplementedError) 462 | end 463 | end 464 | 465 | describe '#path' do 466 | it "returns the path parameter passed to dir's constructor" do 467 | expect(subject.path).to eq '/test' 468 | end 469 | end 470 | 471 | describe '#pos' do 472 | it 'returns the current position in dir' do 473 | 3.times { subject.read } 474 | expect(subject.pos).to eq 3 475 | end 476 | end 477 | 478 | describe '#pos=' do 479 | before { 3.times { subject.read } } 480 | 481 | it 'seeks to a particular location in dir' do 482 | subject.pos = 1 483 | expect(subject.pos).to eq 1 484 | end 485 | 486 | it 'returns the given position' do 487 | expect(subject.pos = 2).to eq 2 488 | end 489 | 490 | context 'when the location has not been seeked yet' do 491 | it "doesn't change the location" do 492 | subject.pos = 42 493 | expect(subject.pos).to eq 3 494 | end 495 | end 496 | 497 | context 'when the location is negative' do 498 | it "doesn't change the location" do 499 | subject.pos = -1 500 | expect(subject.pos).to eq 3 501 | end 502 | end 503 | end 504 | 505 | describe '#read' do 506 | before do 507 | _fs.touch('/test/a') 508 | _fs.touch('/test/b') 509 | end 510 | 511 | it 'reads the next entry from dir and returns it' do 512 | expect(subject.read).to eq '.' 513 | end 514 | 515 | context 'when calling several times' do 516 | it 'returns the next entry each time' do 517 | 2.times { subject.read } 518 | expect(subject.read).to eq 'a' 519 | end 520 | end 521 | 522 | context 'when there are no entries left' do 523 | it 'returns nil' do 524 | 4.times { subject.read } 525 | expect(subject.read).to be_nil 526 | end 527 | end 528 | end 529 | 530 | describe '#rewind' do 531 | it 'repositions dir to the first entry' do 532 | 3.times { subject.read } 533 | subject.rewind 534 | expect(subject.read).to eq '.' 535 | end 536 | 537 | it 'returns the dir itself' do 538 | expect(subject.rewind).to be subject 539 | end 540 | end 541 | 542 | describe '#seek' do 543 | before { 3.times { subject.read } } 544 | 545 | it 'seeks to a particular location in dir' do 546 | subject.seek(1) 547 | expect(subject.pos).to eq 1 548 | end 549 | 550 | it 'returns the dir itself' do 551 | expect(subject.seek(2)).to be subject 552 | end 553 | 554 | context 'when the location has not been seeked yet' do 555 | it "doesn't change the location" do 556 | subject.seek(42) 557 | expect(subject.pos).to eq 3 558 | end 559 | end 560 | 561 | context 'when the location is negative' do 562 | it "doesn't change the location" do 563 | subject.seek(-1) 564 | expect(subject.pos).to eq 3 565 | end 566 | end 567 | end 568 | 569 | describe '#tell' do 570 | it 'returns the current position in dir' do 571 | 3.times { subject.read } 572 | expect(subject.tell).to eq 3 573 | end 574 | end 575 | 576 | describe '#to_path' do 577 | it "returns the path parameter passed to dir's constructor" do 578 | expect(subject.to_path).to eq '/test' 579 | end 580 | end 581 | end 582 | end 583 | -------------------------------------------------------------------------------- /spec/memfs/file/stat_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MemFs 4 | ::RSpec.describe File::Stat do 5 | let(:file_stat) { described_class.new('/test-file') } 6 | let(:dereferenced_file_stat) { described_class.new('/test-file', dereference: true) } 7 | 8 | let(:dir_link_stat) { described_class.new('/test-dir-link') } 9 | let(:dereferenced_dir_link_stat) { described_class.new('/test-dir-link', dereference: true) } 10 | 11 | let(:link_stat) { described_class.new('/test-link') } 12 | let(:dereferenced_link_stat) { described_class.new('/test-link', dereference: true) } 13 | 14 | let(:dir_stat) { described_class.new('/test-dir') } 15 | let(:dereferenced_dir_stat) { described_class.new('/test-dir', dereference: true) } 16 | 17 | let(:entry) { _fs.find!('/test-file') } 18 | 19 | before :each do 20 | _fs.mkdir('/test-dir') 21 | _fs.touch('/test-file') 22 | _fs.symlink('/test-file', '/test-link') 23 | _fs.symlink('/test-dir', '/test-dir-link') 24 | _fs.symlink('/no-file', '/test-no-file-link') 25 | end 26 | 27 | describe '.new' do 28 | context 'when optional dereference argument is set to true' do 29 | context 'when the last target of the link chain does not exist' do 30 | it 'raises an exception' do 31 | expect { 32 | described_class.new('/test-no-file-link', dereference: true) 33 | }.to raise_error(Errno::ENOENT) 34 | end 35 | end 36 | end 37 | end 38 | 39 | describe '#atime' do 40 | let(:time) { Time.now - 500_000 } 41 | 42 | it 'returns the access time of the entry' do 43 | entry = _fs.find!('/test-file') 44 | entry.atime = time 45 | expect(file_stat.atime).to eq(time) 46 | end 47 | 48 | context 'when the entry is a symlink' do 49 | context 'and the optional dereference argument is true' do 50 | it 'returns the access time of the last target of the link chain' do 51 | entry.atime = time 52 | expect(dereferenced_link_stat.atime).to eq(time) 53 | end 54 | end 55 | 56 | context 'and the optional dereference argument is false' do 57 | it 'returns the access time of the symlink itself' do 58 | entry.atime = time 59 | expect(link_stat.atime).not_to eq(time) 60 | end 61 | end 62 | end 63 | end 64 | 65 | describe '#blksize' do 66 | it 'returns the block size of the file' do 67 | expect(file_stat.blksize).to be(4096) 68 | end 69 | end 70 | 71 | describe '#blockdev?' do 72 | context 'when the file is a block device' do 73 | it 'returns true' do 74 | _fs.touch('/block-file') 75 | file = _fs.find('/block-file') 76 | file.block_device = true 77 | block_stat = described_class.new('/block-file') 78 | expect(block_stat.blockdev?).to be true 79 | end 80 | end 81 | 82 | context 'when the file is not a block device' do 83 | it 'returns false' do 84 | expect(file_stat.blockdev?).to be false 85 | end 86 | end 87 | end 88 | 89 | describe '#chardev?' do 90 | context 'when the file is a character device' do 91 | it 'returns true' do 92 | _fs.touch('/character-file') 93 | file = _fs.find('/character-file') 94 | file.character_device = true 95 | character_stat = described_class.new('/character-file') 96 | expect(character_stat.chardev?).to be true 97 | end 98 | end 99 | 100 | context 'when the file is not a character device' do 101 | it 'returns false' do 102 | expect(file_stat.chardev?).to be false 103 | end 104 | end 105 | end 106 | 107 | describe '#ctime' do 108 | let(:time) { Time.now - 500_000 } 109 | 110 | it 'returns the access time of the entry' do 111 | entry.ctime = time 112 | expect(file_stat.ctime).to eq(time) 113 | end 114 | 115 | context 'when the entry is a symlink' do 116 | context 'and the optional dereference argument is true' do 117 | it 'returns the access time of the last target of the link chain' do 118 | entry.ctime = time 119 | expect(dereferenced_link_stat.ctime).to eq(time) 120 | end 121 | end 122 | 123 | context 'and the optional dereference argument is false' do 124 | it 'returns the access time of the symlink itself' do 125 | entry.ctime = time 126 | expect(link_stat.ctime).not_to eq(time) 127 | end 128 | end 129 | end 130 | end 131 | 132 | describe '#dev' do 133 | it 'returns an integer representing the device on which stat resides' do 134 | expect(file_stat.dev).to be_a(Integer) 135 | end 136 | end 137 | 138 | describe '#directory?' do 139 | context 'when dereference is true' do 140 | context 'when the entry is a directory' do 141 | it 'returns true' do 142 | expect(dereferenced_dir_stat.directory?).to be true 143 | end 144 | end 145 | 146 | context 'when the entry is not a directory' do 147 | it 'returns false' do 148 | expect(dereferenced_file_stat.directory?).to be false 149 | end 150 | end 151 | 152 | context 'when the entry is a symlink' do 153 | context 'and the last target of the link chain is a directory' do 154 | it 'returns true' do 155 | expect(dereferenced_dir_link_stat.directory?).to be true 156 | end 157 | end 158 | 159 | context 'and the last target of the link chain is not a directory' do 160 | it 'returns false' do 161 | expect(dereferenced_link_stat.directory?).to be false 162 | end 163 | end 164 | end 165 | end 166 | 167 | context 'when dereference is false' do 168 | context 'when the entry is a directory' do 169 | it 'returns true' do 170 | expect(dir_stat.directory?).to be true 171 | end 172 | end 173 | 174 | context 'when the entry is not a directory' do 175 | it 'returns false' do 176 | expect(file_stat.directory?).to be false 177 | end 178 | end 179 | 180 | context 'when the entry is a symlink' do 181 | context 'and the last target of the link chain is a directory' do 182 | it 'returns false' do 183 | expect(dir_link_stat.directory?).to be false 184 | end 185 | end 186 | 187 | context 'and the last target of the link chain is not a directory' do 188 | it 'returns false' do 189 | expect(link_stat.directory?).to be false 190 | end 191 | end 192 | end 193 | end 194 | end 195 | 196 | describe '#entry' do 197 | it 'returns the comcerned entry' do 198 | expect(file_stat.entry).to be_a(Fake::File) 199 | end 200 | end 201 | 202 | describe '#executable?' do 203 | let(:access) { 0 } 204 | let(:gid) { 0 } 205 | let(:uid) { 0 } 206 | 207 | before :each do 208 | entry.mode = access 209 | entry.uid = uid 210 | entry.gid = gid 211 | end 212 | 213 | context 'when the file is not executable by anyone' do 214 | it 'return false' do 215 | expect(file_stat.executable?).to be false 216 | end 217 | end 218 | 219 | context 'when the file is user executable' do 220 | let(:access) { MemFs::Fake::Entry::UEXEC } 221 | 222 | context 'and the current user owns the file' do 223 | let(:uid) { Process.euid } 224 | 225 | it 'returns true' do 226 | expect(file_stat.executable?).to be true 227 | end 228 | end 229 | end 230 | 231 | context 'when the file is group executable' do 232 | let(:access) { MemFs::Fake::Entry::GEXEC } 233 | 234 | context 'and the current user is part of the owner group' do 235 | let(:gid) { Process.egid } 236 | 237 | it 'returns true' do 238 | expect(file_stat.executable?).to be true 239 | end 240 | end 241 | end 242 | 243 | context 'when the file is executable by anyone' do 244 | let(:access) { MemFs::Fake::Entry::OEXEC } 245 | 246 | context 'and the user has no specific right on it' do 247 | it 'returns true' do 248 | expect(file_stat.executable?).to be true 249 | end 250 | end 251 | end 252 | 253 | context 'when the file does not exist' do 254 | it 'returns false' do 255 | expect(file_stat.executable?).to be false 256 | end 257 | end 258 | end 259 | 260 | describe '#executable_real?' do 261 | let(:access) { 0 } 262 | let(:gid) { 0 } 263 | let(:uid) { 0 } 264 | 265 | before :each do 266 | entry.mode = access 267 | entry.uid = uid 268 | entry.gid = gid 269 | end 270 | 271 | context 'when the file is not executable by anyone' do 272 | it 'return false' do 273 | expect(file_stat.executable_real?).to be false 274 | end 275 | end 276 | 277 | context 'when the file is user executable' do 278 | let(:access) { MemFs::Fake::Entry::UEXEC } 279 | 280 | context 'and the current user owns the file' do 281 | let(:uid) { Process.uid } 282 | 283 | it 'returns true' do 284 | expect(file_stat.executable_real?).to be true 285 | end 286 | end 287 | end 288 | 289 | context 'when the file is group executable' do 290 | let(:access) { MemFs::Fake::Entry::GEXEC } 291 | 292 | context 'and the current user is part of the owner group' do 293 | let(:gid) { Process.gid } 294 | 295 | it 'returns true' do 296 | expect(file_stat.executable_real?).to be true 297 | end 298 | end 299 | end 300 | 301 | context 'when the file is executable by anyone' do 302 | let(:access) { MemFs::Fake::Entry::OEXEC } 303 | 304 | context 'and the user has no specific right on it' do 305 | it 'returns true' do 306 | expect(file_stat.executable_real?).to be true 307 | end 308 | end 309 | end 310 | 311 | context 'when the file does not exist' do 312 | it 'returns false' do 313 | expect(file_stat.executable_real?).to be false 314 | end 315 | end 316 | end 317 | 318 | describe '#file?' do 319 | context 'when dereference is true' do 320 | context 'when the entry is a regular file' do 321 | it 'returns true' do 322 | expect(dereferenced_file_stat.file?).to be true 323 | end 324 | end 325 | 326 | context 'when the entry is not a regular file' do 327 | it 'returns false' do 328 | expect(dereferenced_dir_stat.file?).to be false 329 | end 330 | end 331 | 332 | context 'when the entry is a symlink' do 333 | context 'and the last target of the link chain is a regular file' do 334 | it 'returns true' do 335 | expect(dereferenced_link_stat.file?).to be true 336 | end 337 | end 338 | 339 | context 'and the last target of the link chain is not a regular file' do 340 | it 'returns false' do 341 | expect(dereferenced_dir_link_stat.file?).to be false 342 | end 343 | end 344 | end 345 | end 346 | 347 | context 'when dereference is false' do 348 | context 'when the entry is a regular file' do 349 | it 'returns true' do 350 | expect(file_stat.file?).to be true 351 | end 352 | end 353 | 354 | context 'when the entry is not a regular file' do 355 | it 'returns false' do 356 | expect(dir_stat.file?).to be false 357 | end 358 | end 359 | 360 | context 'when the entry is a symlink' do 361 | context 'and the last target of the link chain is a regular file' do 362 | it 'returns false' do 363 | expect(link_stat.file?).to be false 364 | end 365 | end 366 | 367 | context 'and the last target of the link chain is not a regular file' do 368 | it 'returns false' do 369 | expect(dir_link_stat.file?).to be false 370 | end 371 | end 372 | end 373 | end 374 | end 375 | 376 | describe '#nlink' do 377 | context 'when the entry is a regular file' do 378 | it "returns expected nlinks for a file" do 379 | expect(file_stat.nlink).to eq(1) 380 | end 381 | 382 | it "returns expected nlinks for a directory" do 383 | expect(dir_stat.nlink).to eq(2) 384 | end 385 | end 386 | end 387 | 388 | describe '#ftype' do 389 | context 'when the entry is a regular file' do 390 | it "returns 'file'" do 391 | expect(file_stat.ftype).to eq('file') 392 | end 393 | end 394 | 395 | context 'when the entry is a directory' do 396 | it "returns 'directory'" do 397 | expect(dir_stat.ftype).to eq('directory') 398 | end 399 | end 400 | 401 | context 'when the entry is a block device' do 402 | it "returns 'blockSpecial'" do 403 | _fs.touch('/block-file') 404 | file = _fs.find('/block-file') 405 | file.block_device = true 406 | block_stat = described_class.new('/block-file') 407 | expect(block_stat.ftype).to eq('blockSpecial') 408 | end 409 | end 410 | 411 | context 'when the entry is a character device' do 412 | it "returns 'characterSpecial'" do 413 | _fs.touch('/character-file') 414 | file = _fs.find('/character-file') 415 | file.character_device = true 416 | character_stat = described_class.new('/character-file') 417 | expect(character_stat.ftype).to eq('characterSpecial') 418 | end 419 | end 420 | 421 | context 'when the entry is a symlink' do 422 | it "returns 'link'" do 423 | expect(link_stat.ftype).to eq('link') 424 | end 425 | end 426 | 427 | # fifo and socket not handled for now 428 | 429 | context 'when the entry has no specific type' do 430 | it "returns 'unknown'" do 431 | root = _fs.find('/') 432 | root.add_entry Fake::Entry.new('test-entry') 433 | entry_stat = described_class.new('/test-entry') 434 | expect(entry_stat.ftype).to eq('unknown') 435 | end 436 | end 437 | end 438 | 439 | describe '#gid' do 440 | it 'returns the group id of the named entry' do 441 | _fs.chown(nil, 42, '/test-file') 442 | expect(file_stat.gid).to be(42) 443 | end 444 | end 445 | 446 | describe '#grpowned?' do 447 | context 'when the effective user group owns of the file' do 448 | it 'returns true' do 449 | _fs.chown(0, Process.egid, '/test-file') 450 | expect(file_stat.grpowned?).to be true 451 | end 452 | end 453 | 454 | context 'when the effective user group does not own of the file' do 455 | it 'returns false' do 456 | _fs.chown(0, 0, '/test-file') 457 | expect(file_stat.grpowned?).to be false 458 | end 459 | end 460 | end 461 | 462 | describe '#ino' do 463 | it 'returns the inode number for stat.' do 464 | expect(file_stat.ino).to be_a(Integer) 465 | end 466 | end 467 | 468 | describe '#mode' do 469 | it 'returns an integer representing the permission bits of stat' do 470 | _fs.chmod(0o777, '/test-file') 471 | expect(file_stat.mode).to be(0o100777) 472 | end 473 | end 474 | 475 | describe '#owned?' do 476 | context 'when the effective user owns of the file' do 477 | it 'returns true' do 478 | _fs.chown(Process.euid, 0, '/test-file') 479 | expect(file_stat.owned?).to be true 480 | end 481 | end 482 | 483 | context 'when the effective user does not own of the file' do 484 | it 'returns false' do 485 | _fs.chown(0, 0, '/test-file') 486 | expect(file_stat.owned?).to be false 487 | end 488 | end 489 | end 490 | 491 | describe '#pipe?' do 492 | # Pipes are not handled for now 493 | 494 | context 'when the file is not a pipe' do 495 | it 'returns false' do 496 | expect(file_stat.pipe?).to be false 497 | end 498 | end 499 | end 500 | 501 | describe '#readable?' do 502 | let(:access) { 0 } 503 | let(:gid) { 0 } 504 | let(:uid) { 0 } 505 | 506 | before :each do 507 | entry.mode = access 508 | entry.uid = uid 509 | entry.gid = gid 510 | end 511 | 512 | context 'when the file is not readable by anyone' do 513 | it 'return false' do 514 | expect(file_stat.readable?).to be false 515 | end 516 | end 517 | 518 | context 'when the file is user readable' do 519 | let(:access) { MemFs::Fake::Entry::UREAD } 520 | 521 | context 'and the current user owns the file' do 522 | let(:uid) { Process.euid } 523 | 524 | it 'returns true' do 525 | expect(file_stat.readable?).to be true 526 | end 527 | end 528 | end 529 | 530 | context 'when the file is group readable' do 531 | let(:access) { MemFs::Fake::Entry::GREAD } 532 | 533 | context 'and the current user is part of the owner group' do 534 | let(:gid) { Process.egid } 535 | 536 | it 'returns true' do 537 | expect(file_stat.readable?).to be true 538 | end 539 | end 540 | end 541 | 542 | context 'when the file is readable by anyone' do 543 | let(:access) { MemFs::Fake::Entry::OREAD } 544 | 545 | context 'and the user has no specific right on it' do 546 | it 'returns true' do 547 | expect(file_stat.readable?).to be true 548 | end 549 | end 550 | end 551 | end 552 | 553 | describe '#readable_real?' do 554 | let(:access) { 0 } 555 | let(:gid) { 0 } 556 | let(:uid) { 0 } 557 | 558 | before :each do 559 | entry.mode = access 560 | entry.uid = uid 561 | entry.gid = gid 562 | end 563 | 564 | context 'when the file is not readable by anyone' do 565 | it 'return false' do 566 | expect(file_stat.readable_real?).to be false 567 | end 568 | end 569 | 570 | context 'when the file is user readable' do 571 | let(:access) { MemFs::Fake::Entry::UREAD } 572 | 573 | context 'and the current user owns the file' do 574 | let(:uid) { Process.euid } 575 | 576 | it 'returns true' do 577 | expect(file_stat.readable_real?).to be true 578 | end 579 | end 580 | end 581 | 582 | context 'when the file is group readable' do 583 | let(:access) { MemFs::Fake::Entry::GREAD } 584 | 585 | context 'and the current user is part of the owner group' do 586 | let(:gid) { Process.egid } 587 | 588 | it 'returns true' do 589 | expect(file_stat.readable_real?).to be true 590 | end 591 | end 592 | end 593 | 594 | context 'when the file is readable by anyone' do 595 | let(:access) { MemFs::Fake::Entry::OREAD } 596 | 597 | context 'and the user has no specific right on it' do 598 | it 'returns true' do 599 | expect(file_stat.readable_real?).to be true 600 | end 601 | end 602 | end 603 | 604 | context 'when the file does not exist' do 605 | it 'returns false' do 606 | expect(file_stat.readable_real?).to be false 607 | end 608 | end 609 | end 610 | 611 | describe '#setgid?' do 612 | context 'when the file has the setgid bit set' do 613 | it 'returns true' do 614 | _fs.chmod(0o2000, '/test-file') 615 | expect(file_stat.setgid?).to be true 616 | end 617 | end 618 | 619 | context 'when the file does not have the setgid bit set' do 620 | it 'returns false' do 621 | _fs.chmod(0o644, '/test-file') 622 | expect(file_stat.setgid?).to be false 623 | end 624 | end 625 | end 626 | 627 | describe '#setuid?' do 628 | context 'when the file has the setuid bit set' do 629 | it 'returns true' do 630 | _fs.chmod(0o4000, '/test-file') 631 | expect(file_stat.setuid?).to be true 632 | end 633 | end 634 | 635 | context 'when the file does not have the setuid bit set' do 636 | it 'returns false' do 637 | _fs.chmod(0o644, '/test-file') 638 | expect(file_stat.setuid?).to be false 639 | end 640 | end 641 | end 642 | 643 | describe '#socket?' do 644 | # Sockets are not handled for now 645 | 646 | context 'when the file is not a socket' do 647 | it 'returns false' do 648 | expect(file_stat.socket?).to be false 649 | end 650 | end 651 | end 652 | 653 | describe '#sticky?' do 654 | it 'returns true if the named file has the sticky bit set' do 655 | _fs.chmod(0o1777, '/test-file') 656 | expect(file_stat.sticky?).to be true 657 | end 658 | 659 | it "returns false if the named file hasn't' the sticky bit set" do 660 | _fs.chmod(0o666, '/test-file') 661 | expect(file_stat.sticky?).to be false 662 | end 663 | end 664 | 665 | describe '#symlink?' do 666 | context 'when dereference is true' do 667 | context 'when the entry is a symlink' do 668 | it 'returns false' do 669 | expect(dereferenced_link_stat.symlink?).to be false 670 | end 671 | end 672 | 673 | context 'when the entry is not a symlink' do 674 | it 'returns false' do 675 | expect(dereferenced_file_stat.symlink?).to be false 676 | end 677 | end 678 | end 679 | 680 | context 'when dereference is false' do 681 | context 'when the entry is a symlink' do 682 | it 'returns true' do 683 | expect(link_stat.symlink?).to be true 684 | end 685 | end 686 | 687 | context 'when the entry is not a symlink' do 688 | it 'returns false' do 689 | expect(file_stat.symlink?).to be false 690 | end 691 | end 692 | end 693 | end 694 | 695 | describe '#uid' do 696 | it 'returns the user id of the named entry' do 697 | _fs.chown(42, nil, '/test-file') 698 | expect(file_stat.uid).to be(42) 699 | end 700 | end 701 | 702 | describe '#world_reable?' do 703 | context 'when +file_name+ is readable by others' do 704 | it 'returns an integer representing the file permission bits of +file_name+' do 705 | _fs.chmod(MemFs::Fake::Entry::OREAD, '/test-file') 706 | expect(file_stat.world_readable?).to eq(MemFs::Fake::Entry::OREAD) 707 | end 708 | end 709 | 710 | context 'when +file_name+ is not readable by others' do 711 | it 'returns nil' do 712 | _fs.chmod(MemFs::Fake::Entry::UREAD, '/test-file') 713 | expect(file_stat.world_readable?).to be_nil 714 | end 715 | end 716 | end 717 | 718 | describe '#world_writable?' do 719 | context 'when +file_name+ is writable by others' do 720 | it 'returns an integer representing the file permission bits of +file_name+' do 721 | _fs.chmod(MemFs::Fake::Entry::OWRITE, '/test-file') 722 | expect(file_stat.world_writable?).to eq(MemFs::Fake::Entry::OWRITE) 723 | end 724 | end 725 | 726 | context 'when +file_name+ is not writable by others' do 727 | it 'returns nil' do 728 | _fs.chmod(MemFs::Fake::Entry::UWRITE, '/test-file') 729 | expect(file_stat.world_writable?).to be_nil 730 | end 731 | end 732 | end 733 | 734 | describe '#writable?' do 735 | let(:access) { 0 } 736 | let(:gid) { 0 } 737 | let(:uid) { 0 } 738 | 739 | before :each do 740 | entry.mode = access 741 | entry.uid = uid 742 | entry.gid = gid 743 | end 744 | 745 | context 'when the file is not executable by anyone' do 746 | it 'return false' do 747 | expect(file_stat.writable?).to be false 748 | end 749 | end 750 | 751 | context 'when the file is user executable' do 752 | let(:access) { MemFs::Fake::Entry::UWRITE } 753 | 754 | context 'and the current user owns the file' do 755 | let(:uid) { Process.euid } 756 | 757 | it 'returns true' do 758 | expect(file_stat.writable?).to be true 759 | end 760 | end 761 | end 762 | 763 | context 'when the file is group executable' do 764 | let(:access) { MemFs::Fake::Entry::GWRITE } 765 | 766 | context 'and the current user is part of the owner group' do 767 | let(:gid) { Process.egid } 768 | 769 | it 'returns true' do 770 | expect(file_stat.writable?).to be true 771 | end 772 | end 773 | end 774 | 775 | context 'when the file is executable by anyone' do 776 | let(:access) { MemFs::Fake::Entry::OWRITE } 777 | 778 | context 'and the user has no specific right on it' do 779 | it 'returns true' do 780 | expect(file_stat.writable?).to be true 781 | end 782 | end 783 | end 784 | 785 | context 'when the file does not exist' do 786 | it 'returns false' do 787 | expect(file_stat.writable?).to be false 788 | end 789 | end 790 | end 791 | 792 | describe '#writable_real?' do 793 | let(:access) { 0 } 794 | let(:gid) { 0 } 795 | let(:uid) { 0 } 796 | 797 | before :each do 798 | entry.mode = access 799 | entry.uid = uid 800 | entry.gid = gid 801 | end 802 | 803 | context 'when the file is not executable by anyone' do 804 | it 'return false' do 805 | expect(file_stat.writable_real?).to be false 806 | end 807 | end 808 | 809 | context 'when the file is user executable' do 810 | let(:access) { MemFs::Fake::Entry::UWRITE } 811 | 812 | context 'and the current user owns the file' do 813 | let(:uid) { Process.euid } 814 | 815 | it 'returns true' do 816 | expect(file_stat.writable_real?).to be true 817 | end 818 | end 819 | end 820 | 821 | context 'when the file is group executable' do 822 | let(:access) { MemFs::Fake::Entry::GWRITE } 823 | 824 | context 'and the current user is part of the owner group' do 825 | let(:gid) { Process.egid } 826 | 827 | it 'returns true' do 828 | expect(file_stat.writable_real?).to be true 829 | end 830 | end 831 | end 832 | 833 | context 'when the file is executable by anyone' do 834 | let(:access) { MemFs::Fake::Entry::OWRITE } 835 | 836 | context 'and the user has no specific right on it' do 837 | it 'returns true' do 838 | expect(file_stat.writable_real?).to be true 839 | end 840 | end 841 | end 842 | 843 | context 'when the file does not exist' do 844 | it 'returns false' do 845 | expect(file_stat.writable_real?).to be false 846 | end 847 | end 848 | end 849 | 850 | describe '#zero?' do 851 | context 'when the file has a zero size' do 852 | it 'returns true' do 853 | expect(file_stat.zero?).to be true 854 | end 855 | end 856 | 857 | context 'when the file does not have a zero size' do 858 | before :each do 859 | _fs.find!('/test-file').content << 'test' 860 | end 861 | 862 | it 'returns false' do 863 | expect(file_stat.zero?).to be false 864 | end 865 | end 866 | end 867 | end 868 | end 869 | -------------------------------------------------------------------------------- /spec/fileutils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'fileutils' 3 | require 'spec_helper' 4 | 5 | RSpec.describe FileUtils do 6 | before :each do 7 | MemFs::File.umask(0o020) 8 | MemFs.activate! 9 | 10 | described_class.mkdir '/test' 11 | end 12 | 13 | after :each do 14 | MemFs.deactivate! 15 | end 16 | 17 | describe '.cd' do 18 | it 'changes the current working directory' do 19 | described_class.cd '/test' 20 | expect(described_class.pwd).to eq('/test') 21 | end 22 | 23 | if MemFs.ruby_version_gte?('2.6') 24 | it 'returns 0' do 25 | expect(described_class.cd('/test')).to eq 0 26 | end 27 | else 28 | it 'returns nil' do 29 | expect(described_class.cd('/test')).to be_nil 30 | end 31 | end 32 | 33 | it "raises an error when the given path doesn't exist" do 34 | expect { described_class.cd('/nowhere') }.to raise_error(Errno::ENOENT) 35 | end 36 | 37 | it 'raises an error when the given path is not a directory' do 38 | described_class.touch('/test-file') 39 | expect { described_class.cd('/test-file') }.to raise_error(Errno::ENOTDIR) 40 | end 41 | 42 | context 'when called with a block' do 43 | it 'changes current working directory for the block execution' do 44 | described_class.cd '/test' do 45 | expect(described_class.pwd).to eq('/test') 46 | end 47 | end 48 | 49 | it 'resumes to the old working directory after the block execution finished' do 50 | described_class.cd '/' 51 | expect { described_class.cd('/test') {} }.to_not change { described_class.pwd } 52 | end 53 | end 54 | 55 | context 'when the destination is a symlink' do 56 | before :each do 57 | described_class.symlink('/test', '/test-link') 58 | end 59 | 60 | it 'changes directory to the last target of the link chain' do 61 | described_class.cd('/test-link') 62 | expect(described_class.pwd).to eq('/test') 63 | end 64 | 65 | it "raises an error if the last target of the link chain doesn't exist" do 66 | expect { described_class.cd('/nowhere-link') }.to raise_error(Errno::ENOENT) 67 | end 68 | end 69 | end 70 | 71 | describe '.chmod' do 72 | it 'changes permission bits on the named file to the bit pattern represented by mode' do 73 | described_class.touch '/test-file' 74 | described_class.chmod 0o777, '/test-file' 75 | expect(File.stat('/test-file').mode).to eq(0o100777) 76 | end 77 | 78 | it 'changes permission bits on the named files (in list) to the bit pattern represented by mode' do 79 | described_class.touch ['/test-file', '/test-file2'] 80 | described_class.chmod 0o777, ['/test-file', '/test-file2'] 81 | expect(File.stat('/test-file2').mode).to eq(0o100777) 82 | end 83 | 84 | it 'returns an array containing the file names' do 85 | file_names = %w[/test-file /test-file2] 86 | described_class.touch file_names 87 | expect(described_class.chmod(0o777, file_names)).to eq(file_names) 88 | end 89 | 90 | it 'raises an error if an entry does not exist' do 91 | expect { described_class.chmod(0o777, '/test-file') }.to raise_error(Errno::ENOENT) 92 | end 93 | 94 | context 'when the named file is a symlink' do 95 | before :each do 96 | described_class.touch '/test-file' 97 | described_class.symlink '/test-file', '/test-link' 98 | end 99 | 100 | context 'when File responds to lchmod' do 101 | it 'changes the mode on the link' do 102 | described_class.chmod(0o777, '/test-link') 103 | expect(File.lstat('/test-link').mode).to eq(0o100777) 104 | end 105 | 106 | it "doesn't change the mode of the link's target" do 107 | mode = File.lstat('/test-file').mode 108 | described_class.chmod(0o777, '/test-link') 109 | expect(File.lstat('/test-file').mode).to eq(mode) 110 | end 111 | end 112 | 113 | context "when File doesn't respond to lchmod" do 114 | it 'does nothing' do 115 | allow_any_instance_of(described_class::Entry_).to \ 116 | receive_messages(have_lchmod?: false) 117 | mode = File.lstat('/test-link').mode 118 | described_class.chmod(0o777, '/test-link') 119 | expect(File.lstat('/test-link').mode).to eq(mode) 120 | end 121 | end 122 | end 123 | end 124 | 125 | describe '.chmod_R' do 126 | before :each do 127 | described_class.touch '/test/test-file' 128 | end 129 | 130 | it 'changes the permission bits on the named entry' do 131 | described_class.chmod_R(0o777, '/test') 132 | expect(File.stat('/test').mode).to eq(0o100777) 133 | end 134 | 135 | it 'changes the permission bits on any sub-directory of the named entry' do 136 | described_class.chmod_R(0o777, '/') 137 | expect(File.stat('/test').mode).to eq(0o100777) 138 | end 139 | 140 | it 'changes the permission bits on any descendant file of the named entry' do 141 | described_class.chmod_R(0o777, '/') 142 | expect(File.stat('/test/test-file').mode).to eq(0o100777) 143 | end 144 | end 145 | 146 | describe '.chown' do 147 | it 'changes owner on the named file' do 148 | described_class.chown(42, nil, '/test') 149 | expect(File.stat('/test').uid).to eq(42) 150 | end 151 | 152 | it 'changes owner on the named files (in list)' do 153 | described_class.touch('/test-file') 154 | described_class.chown(42, nil, ['/test', '/test-file']) 155 | expect(File.stat('/test-file').uid).to eq(42) 156 | end 157 | 158 | it 'changes group on the named entry' do 159 | described_class.chown(nil, 42, '/test') 160 | expect(File.stat('/test').gid).to eq(42) 161 | end 162 | 163 | it 'changes group on the named entries in list' do 164 | described_class.touch('/test-file') 165 | described_class.chown(nil, 42, ['/test', '/test-file']) 166 | expect(File.stat('/test-file').gid).to eq(42) 167 | end 168 | 169 | it "doesn't change user if user is nil" do 170 | described_class.chown(nil, 42, '/test') 171 | expect(File.stat('/test').uid).not_to be(42) 172 | end 173 | 174 | it "doesn't change group if group is nil" do 175 | described_class.chown(42, nil, '/test') 176 | expect(File.stat('/test').gid).not_to be(42) 177 | end 178 | 179 | context 'when the name entry is a symlink' do 180 | before :each do 181 | described_class.touch '/test-file' 182 | described_class.symlink '/test-file', '/test-link' 183 | end 184 | 185 | it 'changes the owner on the last target of the link chain' do 186 | described_class.chown(42, nil, '/test-link') 187 | expect(File.stat('/test-file').uid).to eq(42) 188 | end 189 | 190 | it 'changes the group on the last target of the link chain' do 191 | described_class.chown(nil, 42, '/test-link') 192 | expect(File.stat('/test-file').gid).to eq(42) 193 | end 194 | 195 | it "doesn't change the owner of the symlink" do 196 | described_class.chown(42, nil, '/test-link') 197 | expect(File.lstat('/test-link').uid).not_to be(42) 198 | end 199 | 200 | it "doesn't change the group of the symlink" do 201 | described_class.chown(nil, 42, '/test-link') 202 | expect(File.lstat('/test-link').gid).not_to be(42) 203 | end 204 | end 205 | end 206 | 207 | describe '.chown_R' do 208 | before :each do 209 | described_class.touch '/test/test-file' 210 | end 211 | 212 | it 'changes the owner on the named entry' do 213 | described_class.chown_R(42, nil, '/test') 214 | expect(File.stat('/test').uid).to eq(42) 215 | end 216 | 217 | it 'changes the group on the named entry' do 218 | described_class.chown_R(nil, 42, '/test') 219 | expect(File.stat('/test').gid).to eq(42) 220 | end 221 | 222 | it 'changes the owner on any sub-directory of the named entry' do 223 | described_class.chown_R(42, nil, '/') 224 | expect(File.stat('/test').uid).to eq(42) 225 | end 226 | 227 | it 'changes the group on any sub-directory of the named entry' do 228 | described_class.chown_R(nil, 42, '/') 229 | expect(File.stat('/test').gid).to eq(42) 230 | end 231 | 232 | it 'changes the owner on any descendant file of the named entry' do 233 | described_class.chown_R(42, nil, '/') 234 | expect(File.stat('/test/test-file').uid).to eq(42) 235 | end 236 | 237 | it 'changes the group on any descendant file of the named entry' do 238 | described_class.chown_R(nil, 42, '/') 239 | expect(File.stat('/test/test-file').gid).to eq(42) 240 | end 241 | end 242 | 243 | describe '.cmp' do 244 | it_behaves_like 'aliased method', :cmp, :compare_file 245 | end 246 | 247 | describe '.compare_file' do 248 | it 'returns true if the contents of a file A and a file B are identical' do 249 | File.open('/test-file', 'w') { |f| f.puts 'this is a test' } 250 | File.open('/test-file2', 'w') { |f| f.puts 'this is a test' } 251 | 252 | expect(described_class.compare_file('/test-file', '/test-file2')).to be true 253 | end 254 | 255 | it 'returns false if the contents of a file A and a file B are not identical' do 256 | File.open('/test-file', 'w') { |f| f.puts 'this is a test' } 257 | File.open('/test-file2', 'w') { |f| f.puts 'this is not a test' } 258 | 259 | expect(described_class.compare_file('/test-file', '/test-file2')).to be false 260 | end 261 | end 262 | 263 | describe '.compare_stream' do 264 | it 'returns true if the contents of a stream A and stream B are identical' do 265 | File.open('/test-file', 'w') { |f| f.puts 'this is a test' } 266 | File.open('/test-file2', 'w') { |f| f.puts 'this is a test' } 267 | 268 | file1 = File.open('/test-file') 269 | file2 = File.open('/test-file2') 270 | 271 | expect(described_class.compare_stream(file1, file2)).to be true 272 | end 273 | 274 | it 'returns false if the contents of a stream A and stream B are not identical' do 275 | File.open('/test-file', 'w') { |f| f.puts 'this is a test' } 276 | File.open('/test-file2', 'w') { |f| f.puts 'this is not a test' } 277 | 278 | file1 = File.open('/test-file') 279 | file2 = File.open('/test-file2') 280 | 281 | expect(described_class.compare_stream(file1, file2)).to be false 282 | end 283 | end 284 | 285 | describe '.copy' do 286 | it_behaves_like 'aliased method', :copy, :cp 287 | end 288 | 289 | describe '.copy_entry' do 290 | it 'copies a file system entry +src+ to +dest+' do 291 | File.open('/test-file', 'w') { |f| f.puts 'test' } 292 | described_class.copy_entry('/test-file', '/test-copy') 293 | expect(File.read('/test-copy')).to eq("test\n") 294 | end 295 | 296 | it 'preserves file types' do 297 | described_class.touch('/test-file') 298 | described_class.symlink('/test-file', '/test-link') 299 | described_class.copy_entry('/test-link', '/test-copy') 300 | expect(File.symlink?('/test-copy')).to be true 301 | end 302 | 303 | context 'when +src+ does not exist' do 304 | it 'raises an exception' do 305 | expected_exception = 306 | RUBY_VERSION >= '2.4.0' ? Errno::ENOENT : RuntimeError 307 | 308 | expect { 309 | described_class.copy_entry('/test-file', '/test-copy') 310 | }.to raise_error(expected_exception) 311 | end 312 | end 313 | 314 | context 'when +preserve+ is true' do 315 | let(:time) { Date.parse('2013-01-01') } 316 | 317 | before :each do 318 | described_class.touch('/test-file') 319 | described_class.chown(1042, 1042, '/test-file') 320 | described_class.chmod(0o777, '/test-file') 321 | _fs.find('/test-file').mtime = time 322 | described_class.copy_entry('/test-file', '/test-copy', true) 323 | end 324 | 325 | it 'preserves owner' do 326 | expect(File.stat('/test-copy').uid).to eq(1042) 327 | end 328 | 329 | it 'preserves group' do 330 | expect(File.stat('/test-copy').gid).to eq(1042) 331 | end 332 | 333 | it 'preserves permissions' do 334 | expect(File.stat('/test-copy').mode).to eq(0o100777) 335 | end 336 | 337 | it 'preserves modified time' do 338 | expect(File.stat('/test-copy').mtime).to eq(time) 339 | end 340 | end 341 | 342 | context 'when +dest+ already exists' do 343 | it 'overwrite it' do 344 | File.open('/test-file', 'w') { |f| f.puts 'test' } 345 | described_class.touch('/test-copy') 346 | described_class.copy_entry('/test-file', '/test-copy') 347 | expect(File.read('/test-copy')).to eq("test\n") 348 | end 349 | end 350 | 351 | context 'when +remove_destination+ is true' do 352 | it 'removes each destination file before copy' do 353 | described_class.touch(['/test-file', '/test-copy']) 354 | expect(File).to receive(:unlink).with('/test-copy') 355 | described_class.copy_entry('/test-file', '/test-copy', false, false, true) 356 | end 357 | end 358 | 359 | context 'when +src+ is a directory' do 360 | it 'copies its contents recursively' do 361 | described_class.mkdir_p('/test-dir/test-sub-dir') 362 | described_class.copy_entry('/test-dir', '/test-copy') 363 | expect(Dir.exist?('/test-copy/test-sub-dir')).to be true 364 | end 365 | end 366 | end 367 | 368 | describe '.copy_file' do 369 | it 'copies file contents of src to dest' do 370 | File.open('/test-file', 'w') { |f| f.puts 'test' } 371 | described_class.copy_file('/test-file', '/test-file2') 372 | expect(File.read('/test-file2')).to eq("test\n") 373 | end 374 | end 375 | 376 | describe '.copy_stream' do 377 | # This method is not implemented since it is delegated to the IO class. 378 | end 379 | 380 | describe '.cp' do 381 | before :each do 382 | File.open('/test-file', 'w') { |f| f.puts 'test' } 383 | end 384 | 385 | it 'copies a file content +src+ to +dest+' do 386 | described_class.cp('/test-file', '/copy-file') 387 | expect(File.read('/copy-file')).to eq("test\n") 388 | end 389 | 390 | context 'when +src+ and +dest+ are the same file' do 391 | it 'raises an error' do 392 | expect { 393 | described_class.cp('/test-file', '/test-file') 394 | }.to raise_error(ArgumentError) 395 | end 396 | end 397 | 398 | context 'when +dest+ is a directory' do 399 | it 'copies +src+ to +dest/src+' do 400 | described_class.mkdir('/dest') 401 | described_class.cp('/test-file', '/dest/copy-file') 402 | expect(File.read('/dest/copy-file')).to eq("test\n") 403 | end 404 | end 405 | 406 | context 'when src is a list of files' do 407 | context 'when +dest+ is not a directory' do 408 | it 'raises an error' do 409 | described_class.touch(['/dest', '/test-file2']) 410 | expect { 411 | described_class.cp(['/test-file', '/test-file2'], '/dest') 412 | }.to raise_error(Errno::ENOTDIR) 413 | end 414 | end 415 | end 416 | end 417 | 418 | describe '.cp_r' do 419 | it 'copies +src+ to +dest+' do 420 | File.open('/test-file', 'w') { |f| f.puts 'test' } 421 | 422 | described_class.cp_r('/test-file', '/copy-file') 423 | expect(File.read('/copy-file')).to eq("test\n") 424 | end 425 | 426 | context 'when +src+ is a directory' do 427 | it 'copies all its contents recursively' do 428 | described_class.mkdir('/test/dir') 429 | described_class.touch('/test/dir/file') 430 | 431 | described_class.cp_r('/test', '/dest') 432 | expect(File.exist?('/dest/dir/file')).to be true 433 | end 434 | end 435 | 436 | context 'when +dest+ is a directory' do 437 | it 'copies +src+ to +dest/src+' do 438 | described_class.mkdir(['/test/dir', '/dest']) 439 | described_class.touch('/test/dir/file') 440 | 441 | described_class.cp_r('/test', '/dest') 442 | expect(File.exist?('/dest/test/dir/file')).to be true 443 | end 444 | end 445 | 446 | context 'when +src+ is a list of files' do 447 | it 'copies each of them in +dest+' do 448 | described_class.mkdir(['/test/dir', '/test/dir2', '/dest']) 449 | described_class.touch(['/test/dir/file', '/test/dir2/file']) 450 | 451 | described_class.cp_r(['/test/dir', '/test/dir2'], '/dest') 452 | expect(File.exist?('/dest/dir2/file')).to be true 453 | end 454 | end 455 | end 456 | 457 | describe '.getwd' do 458 | it_behaves_like 'aliased method', :getwd, :pwd 459 | end 460 | 461 | describe '.identical?' do 462 | it_behaves_like 'aliased method', :identical?, :compare_file 463 | end 464 | 465 | describe '.install' do 466 | before :each do 467 | File.open('/test-file', 'w') { |f| f.puts 'test' } 468 | end 469 | 470 | it 'copies +src+ to +dest+' do 471 | described_class.install('/test-file', '/test-file2') 472 | expect(File.read('/test-file2')).to eq("test\n") 473 | end 474 | 475 | context 'when +:mode+ is set' do 476 | it 'changes the permission mode to +mode+' do 477 | expect(File).to receive(:chmod).with(0o777, '/test-file2') 478 | described_class.install('/test-file', '/test-file2', mode: 0o777) 479 | end 480 | end 481 | 482 | context 'when +src+ and +dest+ are the same file' do 483 | it 'raises an exception' do 484 | expect { 485 | described_class.install('/test-file', '/test-file') 486 | }.to raise_error ArgumentError 487 | end 488 | end 489 | 490 | context 'when +dest+ already exists' do 491 | it 'removes destination before copy' do 492 | expect(File).to receive(:unlink).with('/test-file2') 493 | described_class.install('/test-file', '/test-file2') 494 | end 495 | 496 | context 'and +dest+ is a directory' do 497 | it 'installs +src+ in dest/src' do 498 | described_class.mkdir('/test-dir') 499 | described_class.install('/test-file', '/test-dir') 500 | expect(File.read('/test-dir/test-file')).to eq("test\n") 501 | end 502 | end 503 | end 504 | end 505 | 506 | describe '.link' do 507 | it_behaves_like 'aliased method', :link, :ln 508 | end 509 | 510 | describe '.ln' do 511 | before :each do 512 | File.open('/test-file', 'w') { |f| f.puts 'test' } 513 | end 514 | 515 | it 'creates a hard link +dest+ which points to +src+' do 516 | described_class.ln('/test-file', '/test-file2') 517 | expect(File.read('/test-file2')).to eq(File.read('/test-file')) 518 | end 519 | 520 | it 'creates a hard link, not a symlink' do 521 | described_class.ln('/test-file', '/test-file2') 522 | expect(File.symlink?('/test-file2')).to be false 523 | end 524 | 525 | context 'when +dest+ already exists' do 526 | context 'and is a directory' do 527 | it 'creates a link dest/src' do 528 | described_class.mkdir('/test-dir') 529 | described_class.ln('/test-file', '/test-dir') 530 | expect(File.read('/test-dir/test-file')).to eq(File.read('/test-file')) 531 | end 532 | end 533 | 534 | context 'and it is not a directory' do 535 | it 'raises an exception' do 536 | described_class.touch('/test-file2') 537 | expect { 538 | described_class.ln('/test-file', '/test-file2') 539 | }.to raise_error(SystemCallError) 540 | end 541 | 542 | context 'and +:force+ is set' do 543 | it 'overwrites +dest+' do 544 | described_class.touch('/test-file2') 545 | described_class.ln('/test-file', '/test-file2', force: true) 546 | expect(File.read('/test-file2')).to eq(File.read('/test-file')) 547 | end 548 | end 549 | end 550 | end 551 | 552 | context 'when passing a list of paths' do 553 | it 'creates a link for each path in +destdir+' do 554 | described_class.touch('/test-file2') 555 | described_class.mkdir('/test-dir') 556 | described_class.ln(['/test-file', '/test-file2'], '/test-dir') 557 | end 558 | 559 | context 'and +destdir+ is not a directory' do 560 | it 'raises an exception' do 561 | described_class.touch(['/test-file2', '/not-a-dir']) 562 | expect { 563 | described_class.ln(['/test-file', '/test-file2'], '/not-a-dir') 564 | }.to raise_error(Errno::ENOTDIR) 565 | end 566 | end 567 | end 568 | end 569 | 570 | describe '.ln_s' do 571 | before :each do 572 | File.open('/test-file', 'w') { |f| f.puts 'test' } 573 | described_class.touch('/not-a-dir') 574 | described_class.mkdir('/test-dir') 575 | end 576 | 577 | it 'creates a symbolic link +new+' do 578 | described_class.ln_s('/test-file', '/test-link') 579 | expect(File.symlink?('/test-link')).to be true 580 | end 581 | 582 | it 'creates a symbolic link which points to +old+' do 583 | described_class.ln_s('/test-file', '/test-link') 584 | expect(File.read('/test-link')).to eq(File.read('/test-file')) 585 | end 586 | 587 | context 'when +new+ already exists' do 588 | context 'and it is a directory' do 589 | it 'creates a symbolic link +new/old+' do 590 | described_class.ln_s('/test-file', '/test-dir') 591 | expect(File.symlink?('/test-dir/test-file')).to be true 592 | end 593 | end 594 | 595 | context 'and it is not a directory' do 596 | it 'raises an exeption' do 597 | expect { 598 | described_class.ln_s('/test-file', '/not-a-dir') 599 | }.to raise_error(Errno::EEXIST) 600 | end 601 | 602 | context 'and +:force+ is set' do 603 | it 'overwrites +new+' do 604 | described_class.ln_s('/test-file', '/not-a-dir', force: true) 605 | expect(File.symlink?('/not-a-dir')).to be true 606 | end 607 | end 608 | end 609 | end 610 | 611 | context 'when passing a list of paths' do 612 | before :each do 613 | File.open('/test-file2', 'w') { |f| f.puts 'test2' } 614 | end 615 | 616 | it 'creates several symbolic links in +destdir+' do 617 | described_class.ln_s(['/test-file', '/test-file2'], '/test-dir') 618 | expect(File.exist?('/test-dir/test-file2')).to be true 619 | end 620 | 621 | it 'creates symbolic links pointing to each item in the list' do 622 | described_class.ln_s(['/test-file', '/test-file2'], '/test-dir') 623 | expect(File.read('/test-dir/test-file2')).to eq(File.read('/test-file2')) 624 | end 625 | 626 | context 'when +destdir+ is not a directory' do 627 | it 'raises an error' do 628 | expect { 629 | described_class.ln_s(['/test-file', '/test-file2'], '/not-a-dir') 630 | }.to raise_error(Errno::ENOTDIR) 631 | end 632 | end 633 | end 634 | end 635 | 636 | describe '.ln_sf' do 637 | it 'calls ln_s with +:force+ set to true' do 638 | File.open('/test-file', 'w') { |f| f.puts 'test' } 639 | File.open('/test-file2', 'w') { |f| f.puts 'test2' } 640 | described_class.ln_sf('/test-file', '/test-file2') 641 | expect(File.read('/test-file2')).to eq(File.read('/test-file')) 642 | end 643 | end 644 | 645 | describe '.makedirs' do 646 | it_behaves_like 'aliased method', :makedirs, :mkdir_p 647 | end 648 | 649 | describe '.mkdir' do 650 | it 'creates one directory' do 651 | described_class.mkdir('/test-dir') 652 | expect(File.directory?('/test-dir')).to be true 653 | end 654 | 655 | context 'when passing a list of paths' do 656 | it 'creates several directories' do 657 | described_class.mkdir(['/test-dir', '/test-dir2']) 658 | expect(File.directory?('/test-dir2')).to be true 659 | end 660 | end 661 | 662 | context 'when passing options' do 663 | context 'when passing mode parameter' do 664 | it 'creates directory with specified permissions' do 665 | described_class.mkdir('/test-dir', mode: 0o654) 666 | expect(File.exist?('/test-dir')).to be true 667 | expect(File.stat('/test-dir').mode).to eq(0o100654) 668 | end 669 | end 670 | 671 | context 'when passing noop parameter' do 672 | it 'does not create any directories' do 673 | described_class.mkdir(['/test-dir', '/another-dir'], noop: true) 674 | expect(File.directory?('/test-dir')).to be false 675 | expect(File.directory?('/another-dir')).to be false 676 | end 677 | end 678 | end 679 | end 680 | 681 | describe '.mkdir_p' do 682 | it 'creates a directory' do 683 | described_class.mkdir_p('/test-dir') 684 | expect(File.directory?('/test-dir')).to be true 685 | end 686 | 687 | it 'creates all the parent directories' do 688 | described_class.mkdir_p('/path/to/some/test-dir') 689 | expect(File.directory?('/path/to/some')).to be true 690 | end 691 | 692 | context 'when passing a list of paths' do 693 | it 'creates each directory' do 694 | described_class.mkdir_p(['/test-dir', '/test-dir']) 695 | expect(File.directory?('/test-dir')).to be true 696 | end 697 | 698 | it "creates each directory's parents" do 699 | described_class.mkdir_p(['/test-dir', '/path/to/some/test-dir']) 700 | expect(File.directory?('/path/to/some')).to be true 701 | end 702 | end 703 | 704 | context 'when passing options' do 705 | context 'when passing mode parameter' do 706 | it 'creates directory with specified permissions' do 707 | described_class.mkdir_p('/test-dir', mode: 0o654) 708 | expect(File.exist?('/test-dir')).to be true 709 | expect(File.stat('/test-dir').mode).to eq(0o100654) 710 | end 711 | end 712 | 713 | context 'when passing noop parameter' do 714 | it 'does not create any directories' do 715 | described_class.mkdir_p(['/test-dir', '/another-dir'], noop: true) 716 | expect(File.directory?('/test-dir')).to be false 717 | expect(File.directory?('/another-dir')).to be false 718 | end 719 | end 720 | end 721 | end 722 | 723 | describe '.mkpath' do 724 | it_behaves_like 'aliased method', :mkpath, :mkdir_p 725 | end 726 | 727 | describe '.move' do 728 | it_behaves_like 'aliased method', :move, :mv 729 | end 730 | 731 | describe '.mv' do 732 | it 'moves +src+ to +dest+' do 733 | described_class.touch('/test-file') 734 | described_class.mv('/test-file', '/test-file2') 735 | expect(File.exist?('/test-file2')).to be true 736 | end 737 | 738 | it 'removes +src+' do 739 | described_class.touch('/test-file') 740 | described_class.mv('/test-file', '/test-file2') 741 | expect(File.exist?('/test-file')).to be false 742 | end 743 | 744 | context 'when +dest+ already exists' do 745 | context 'and is a directory' do 746 | it 'moves +src+ to dest/src' do 747 | described_class.touch('/test-file') 748 | described_class.mkdir('/test-dir') 749 | described_class.mv('/test-file', '/test-dir') 750 | expect(File.exist?('/test-dir/test-file')).to be true 751 | end 752 | end 753 | 754 | context 'and +dest+ is not a directory' do 755 | it 'it overwrites +dest+' do 756 | File.open('/test-file', 'w') { |f| f.puts 'test' } 757 | described_class.touch('/test-file2') 758 | described_class.mv('/test-file', '/test-file2') 759 | expect(File.read('/test-file2')).to eq("test\n") 760 | end 761 | end 762 | end 763 | end 764 | 765 | describe '.pwd' do 766 | it 'returns the name of the current directory' do 767 | described_class.cd '/test' 768 | expect(described_class.pwd).to eq('/test') 769 | end 770 | end 771 | 772 | describe '.remove' do 773 | it_behaves_like 'aliased method', :remove, :rm 774 | end 775 | 776 | describe '.remove_dir' do 777 | it 'removes the given directory +dir+' do 778 | described_class.mkdir('/test-dir') 779 | described_class.remove_dir('/test-dir') 780 | expect(File.exist?('/test-dir')).to be false 781 | end 782 | 783 | it 'removes the contents of the given directory +dir+' do 784 | described_class.mkdir_p('/test-dir/test-sub-dir') 785 | described_class.remove_dir('/test-dir') 786 | expect(File.exist?('/test-dir/test-sub-dir')).to be false 787 | end 788 | 789 | context 'when +force+ is set' do 790 | it 'ignores standard errors' do 791 | expect { described_class.remove_dir('/test-dir', true) }.not_to raise_error 792 | end 793 | end 794 | end 795 | 796 | describe '.remove_entry' do 797 | it 'removes a file system entry +path+' do 798 | described_class.touch('/test-file') 799 | described_class.remove_entry('/test-file') 800 | expect(File.exist?('/test-file')).to be false 801 | end 802 | 803 | context 'when +path+ is a directory' do 804 | it 'removes it recursively' do 805 | described_class.mkdir_p('/test-dir/test-sub-dir') 806 | described_class.remove_entry('/test-dir') 807 | expect(Dir.exist?('/test-dir')).to be false 808 | end 809 | end 810 | end 811 | 812 | describe '.remove_entry_secure' do 813 | before :each do 814 | described_class.mkdir_p('/test-dir/test-sub-dir') 815 | end 816 | 817 | it 'removes a file system entry +path+' do 818 | described_class.chmod(0o755, '/') 819 | described_class.remove_entry_secure('/test-dir') 820 | expect(Dir.exist?('/test-dir')).to be false 821 | end 822 | 823 | context 'when +path+ is a directory' do 824 | it 'removes it recursively' do 825 | described_class.chmod(0o755, '/') 826 | described_class.remove_entry_secure('/test-dir') 827 | expect(Dir.exist?('/test-dir/test-sub-dir')).to be false 828 | end 829 | 830 | context 'and is word writable' do 831 | it 'calls chown(2) on it' do 832 | described_class.chmod(0o1777, '/') 833 | directory = _fs.find('/test-dir') 834 | expect(directory).to receive(:uid=).at_least(:once) 835 | described_class.remove_entry_secure('/test-dir') 836 | end 837 | 838 | it 'calls chmod(2) on all sub directories' do 839 | described_class.chmod(0o1777, '/') 840 | directory = _fs.find('/test-dir') 841 | expect(directory).to receive(:mode=).at_least(:once) 842 | described_class.remove_entry_secure('/test-dir') 843 | end 844 | end 845 | end 846 | end 847 | 848 | describe '.remove_file' do 849 | it 'removes a file path' do 850 | described_class.touch('/test-file') 851 | described_class.remove_file('/test-file') 852 | expect(File.exist?('/test-file')).to be false 853 | end 854 | 855 | context 'when +force+ is set' do 856 | it 'ignores StandardError' do 857 | expect { described_class.remove_file('/no-file', true) }.not_to raise_error 858 | end 859 | end 860 | end 861 | 862 | describe '.rm' do 863 | it 'removes the specified file' do 864 | described_class.touch('/test-file') 865 | described_class.rm('/test-file') 866 | expect(File.exist?('/test-file')).to be false 867 | end 868 | 869 | it 'removes files specified in list' do 870 | described_class.touch(['/test-file', '/test-file2']) 871 | described_class.rm(['/test-file', '/test-file2']) 872 | expect(File.exist?('/test-file2')).to be false 873 | end 874 | 875 | it 'cannot remove a directory' do 876 | described_class.mkdir('/test-dir') 877 | expect { described_class.rm('/test-dir') }.to raise_error(Errno::EPERM) 878 | end 879 | 880 | context 'when +:force+ is set' do 881 | it 'ignores StandardError' do 882 | described_class.mkdir('/test-dir') 883 | expect { 884 | described_class.rm('/test-dir', force: true) 885 | }.not_to raise_error 886 | end 887 | end 888 | end 889 | 890 | describe '.rm_f' do 891 | it 'calls rm with +:force+ set to true' do 892 | expect(described_class) 893 | .to receive(:rm).with('test', a_hash_including(force: true)) 894 | 895 | described_class.rm_f('test') 896 | end 897 | end 898 | 899 | describe '.rm_r' do 900 | before :each do 901 | described_class.touch(['/test-file', '/test-file2']) 902 | end 903 | 904 | it 'removes a list of files' do 905 | described_class.rm_r(['/test-file', '/test-file2']) 906 | expect(File.exist?('/test-file2')).to be false 907 | end 908 | 909 | context 'when an item of the list is a directory' do 910 | it 'removes all its contents recursively' do 911 | described_class.mkdir('/test-dir') 912 | described_class.touch('/test-dir/test-file') 913 | described_class.rm_r(['/test-file', '/test-file2', '/test-dir']) 914 | expect(File.exist?('/test-dir/test-file')).to be false 915 | end 916 | end 917 | 918 | context 'when +:force+ is set' do 919 | it 'ignores StandardError' do 920 | expect { 921 | described_class.rm_r(['/no-file'], force: true) 922 | }.not_to raise_error 923 | end 924 | end 925 | end 926 | 927 | describe '.rm_rf' do 928 | it 'calls rm with +:force+ set to true' do 929 | expect(described_class) 930 | .to receive(:rm_r).with('test', a_hash_including(force: true)) 931 | 932 | described_class.rm_rf('test') 933 | end 934 | end 935 | 936 | describe '.rmdir' do 937 | it 'Removes a directory' do 938 | described_class.mkdir('/test-dir') 939 | described_class.rmdir('/test-dir') 940 | expect(Dir.exist?('/test-dir')).to be false 941 | end 942 | 943 | it 'Removes a list of directories' do 944 | described_class.mkdir('/test-dir') 945 | described_class.mkdir('/test-dir2') 946 | described_class.rmdir(['/test-dir', '/test-dir2']) 947 | expect(Dir.exist?('/test-dir2')).to be false 948 | end 949 | 950 | context 'when a directory is not empty' do 951 | before :each do 952 | described_class.mkdir('/test-dir') 953 | described_class.touch('/test-dir/test-file') 954 | end 955 | 956 | if RUBY_VERSION >= '2.5.0' 957 | it 'raises an error' do 958 | expect { described_class.rmdir('/test-dir') } 959 | .to raise_error(Errno::ENOTEMPTY) 960 | end 961 | 962 | it 'doesn’t remove the directory' do 963 | begin 964 | described_class.rmdir('/test-dir') 965 | rescue Errno::ENOTEMPTY 966 | # noop 967 | end 968 | 969 | expect(Dir.exist?('/test-dir')).to be true 970 | end 971 | else 972 | it 'ignores errors' do 973 | expect { described_class.rmdir('/test-dir') }.not_to raise_error 974 | end 975 | 976 | it 'doesn’t remove the directory' do 977 | described_class.rmdir('/test-dir') 978 | expect(Dir.exist?('/test-dir')).to be true 979 | end 980 | end 981 | end 982 | end 983 | 984 | describe '.rmtree' do 985 | it_behaves_like 'aliased method', :rmtree, :rm_rf 986 | end 987 | 988 | describe '.safe_unlink' do 989 | it_behaves_like 'aliased method', :safe_unlink, :rm_f 990 | end 991 | 992 | describe '.symlink' do 993 | it_behaves_like 'aliased method', :symlink, :ln_s 994 | end 995 | 996 | describe '.touch' do 997 | it "creates a file if it doesn't exist" do 998 | described_class.touch('/test-file') 999 | expect(_fs.find('/test-file')).not_to be_nil 1000 | end 1001 | 1002 | it "creates a list of files if they don't exist" do 1003 | described_class.touch(['/test-file', '/test-file2']) 1004 | expect(_fs.find('/test-file2')).not_to be_nil 1005 | end 1006 | end 1007 | 1008 | describe '.uptodate?' do 1009 | before :each do 1010 | described_class.touch('/test-file') 1011 | described_class.touch('/old-file') 1012 | _fs.find!('/old-file').mtime = Time.now - 3600 1013 | end 1014 | 1015 | it 'returns true if +newer+ is newer than all +old_list+' do 1016 | expect(described_class.uptodate?('/test-file', ['/old-file'])).to be true 1017 | end 1018 | 1019 | context 'when +newer+ does not exist' do 1020 | it 'consideres it as older' do 1021 | expect(described_class.uptodate?('/no-file', ['/old-file'])).to be false 1022 | end 1023 | end 1024 | 1025 | context 'when a item of +old_list+ does not exist' do 1026 | it 'consideres it as older than +newer+' do 1027 | uptodate = described_class.uptodate?('/test-file', ['/old-file', '/no-file']) 1028 | expect(uptodate).to be true 1029 | end 1030 | end 1031 | end 1032 | end 1033 | --------------------------------------------------------------------------------