ffi-libfuse 0.0.1.pre → 0.1.0.rc20220550

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +3 -1
  3. data/CHANGES.md +14 -0
  4. data/LICENSE +21 -0
  5. data/README.md +127 -44
  6. data/lib/ffi/accessors.rb +6 -6
  7. data/lib/ffi/boolean_int.rb +27 -0
  8. data/lib/ffi/devt.rb +23 -0
  9. data/lib/ffi/encoding.rb +38 -0
  10. data/lib/ffi/gnu_extensions.rb +1 -1
  11. data/lib/ffi/libfuse/ackbar.rb +6 -8
  12. data/lib/ffi/libfuse/adapter/context.rb +12 -10
  13. data/lib/ffi/libfuse/adapter/fuse2_compat.rb +52 -51
  14. data/lib/ffi/libfuse/adapter/fuse3_support.rb +0 -1
  15. data/lib/ffi/libfuse/adapter/ruby.rb +499 -148
  16. data/lib/ffi/libfuse/adapter/safe.rb +1 -1
  17. data/lib/ffi/libfuse/adapter.rb +1 -2
  18. data/lib/ffi/libfuse/callbacks.rb +1 -1
  19. data/lib/ffi/libfuse/filesystem/accounting.rb +116 -0
  20. data/lib/ffi/libfuse/filesystem/mapped_dir.rb +74 -0
  21. data/lib/ffi/libfuse/filesystem/mapped_files.rb +141 -0
  22. data/lib/ffi/libfuse/filesystem/pass_through_dir.rb +55 -0
  23. data/lib/ffi/libfuse/filesystem/pass_through_file.rb +45 -0
  24. data/lib/ffi/libfuse/filesystem/utils.rb +102 -0
  25. data/lib/ffi/libfuse/filesystem/virtual_dir.rb +306 -0
  26. data/lib/ffi/libfuse/filesystem/virtual_file.rb +94 -0
  27. data/lib/ffi/libfuse/filesystem/virtual_fs.rb +188 -0
  28. data/lib/ffi/libfuse/filesystem/virtual_node.rb +101 -0
  29. data/lib/ffi/libfuse/filesystem.rb +25 -0
  30. data/lib/ffi/libfuse/fuse2.rb +21 -21
  31. data/lib/ffi/libfuse/fuse3.rb +12 -12
  32. data/lib/ffi/libfuse/fuse_args.rb +69 -34
  33. data/lib/ffi/libfuse/fuse_buffer.rb +128 -26
  34. data/lib/ffi/libfuse/fuse_callbacks.rb +1 -5
  35. data/lib/ffi/libfuse/fuse_common.rb +55 -61
  36. data/lib/ffi/libfuse/fuse_config.rb +134 -143
  37. data/lib/ffi/libfuse/fuse_conn_info.rb +310 -134
  38. data/lib/ffi/libfuse/fuse_context.rb +45 -3
  39. data/lib/ffi/libfuse/fuse_operations.rb +43 -19
  40. data/lib/ffi/libfuse/fuse_version.rb +10 -6
  41. data/lib/ffi/libfuse/main.rb +80 -37
  42. data/lib/ffi/libfuse/thread_pool.rb +1 -1
  43. data/lib/ffi/libfuse/version.rb +1 -1
  44. data/lib/ffi/libfuse.rb +13 -4
  45. data/lib/ffi/ruby_object.rb +1 -1
  46. data/lib/ffi/stat/constants.rb +9 -0
  47. data/lib/ffi/stat/native.rb +36 -6
  48. data/lib/ffi/stat/time_spec.rb +28 -12
  49. data/lib/ffi/stat.rb +111 -22
  50. data/lib/ffi/stat_vfs.rb +59 -1
  51. data/lib/ffi/struct_wrapper.rb +22 -1
  52. data/sample/hello_fs.rb +54 -0
  53. data/sample/memory_fs.rb +5 -181
  54. data/sample/no_fs.rb +20 -21
  55. data/sample/pass_through_fs.rb +30 -0
  56. metadata +66 -7
  57. data/lib/ffi/libfuse/adapter/thread_local_context.rb +0 -36
@@ -48,7 +48,7 @@ module FFI
48
48
  -e.errno
49
49
  rescue StandardError, ScriptError => e
50
50
  # rubocop:disable Layout/LineLength
51
- warn "FFI::libfuse error in callback #{fuse_method}: #{e.class.name}: #{e.message}\n\t#{e.backtrace.join("\n\t")}"
51
+ warn ["FFI::Libfuse error in #{fuse_method}", *e.backtrace.reverse, "#{e.class.name}:#{e.message}"].join("\n\t")
52
52
  # rubocop:enable Layout/LineLength
53
53
  -Errno::ENOTRECOVERABLE::Errno
54
54
  end
@@ -11,7 +11,7 @@ module FFI
11
11
  #
12
12
  # These will implement {FuseOperations#fuse_wrappers} to add the proc which can then...
13
13
  #
14
- # * prepend additional arguments - eg. ({Context})
14
+ # * populate thread local information - eg. ({Context})
15
15
  # * wrap common arguments - eg. ({Pathname})
16
16
  # * handle return values/exceptions - eg. ({Safe})
17
17
  # * or just wrap the underlying block - eg. ({Debug})
@@ -70,7 +70,6 @@ module FFI
70
70
  end
71
71
 
72
72
  require_relative 'adapter/context'
73
- require_relative 'adapter/thread_local_context'
74
73
  require_relative 'adapter/debug'
75
74
  require_relative 'adapter/ruby'
76
75
  require_relative 'adapter/interrupt'
@@ -37,7 +37,7 @@ module FFI
37
37
 
38
38
  def initialize_callbacks(callbacks, delegate:, wrappers: [])
39
39
  callbacks.select { |m| respond_to_callback?(m, delegate) }.each do |m|
40
- register(m, wrappers) { |*f_args| delegate.send(m, *f_args) }
40
+ register(m, wrappers) { |*f_args| delegate.public_send(m, *f_args) }
41
41
  end
42
42
  end
43
43
 
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../stat_vfs'
4
+
5
+ module FFI
6
+ module Libfuse
7
+ module Filesystem
8
+ # Helper for filesystem accounting
9
+ class Accounting
10
+ OPTIONS = { 'max_space=' => :max_space, 'max_nodes=' => :max_nodes }.freeze
11
+
12
+ HELP =
13
+ <<~END_HELP
14
+ #{name} options:
15
+ -o max_space=<int> maximum space consumed by files, --ve will always show free space
16
+ -o max_nodes=<int> maximum number of files and directories, -ve will always show free nodes
17
+
18
+ END_HELP
19
+
20
+ def fuse_opt_proc(key:, value:, **)
21
+ return :keep unless OPTIONS.values.include?(key)
22
+
23
+ public_send("#{key}=", value.to_i)
24
+ :handled
25
+ end
26
+
27
+ # @return [Integer] maximum allowed space in bytes
28
+ #
29
+ # Positive values will limit values in {adjust} to stay below this value
30
+ #
31
+ # Negative or zero will simply report this amount of space as 'free' in {to_statvfs}
32
+ attr_accessor :max_space
33
+
34
+ # @return [Integer] maximum number of (virtual) inodes
35
+ #
36
+ # Positive values will limit {adjust} to stay below this value
37
+ #
38
+ # Negative or zero will simply report this number of inodes as 'free' in {to_statvfs}
39
+ attr_accessor :max_nodes
40
+
41
+ # @return [Integer] accumulated space in bytes
42
+ attr_reader :space
43
+
44
+ # @return [Integer] accumulated inodes (typically count of files and directories)
45
+ attr_reader :nodes
46
+
47
+ # @return [Integer] block size for statvfs
48
+ attr_accessor :block_size
49
+
50
+ def initialize(max_space: 0, max_nodes: 0, block_size: 1024)
51
+ @nodes = 0
52
+ @space = 0
53
+ @max_space = max_space
54
+ @max_nodes = max_nodes
55
+ @block_size = block_size
56
+ end
57
+
58
+ # Adjust accumlated statistics
59
+ # @param [Integer] delta_space change in {#space} usage
60
+ # @param [Integer] delta_nodes change in {#nodes} usage
61
+ # @return [self]
62
+ # @raise [Errno::ENOSPC] if adjustment {#space}/{#nodes} would exceed {#max_space} or {#max_nodes}
63
+ def adjust(delta_space, delta_nodes = 0, strict: true)
64
+ strict_space = strict && delta_space.positive? && max_space.positive?
65
+ raise Errno::ENOSPC if strict_space && space + delta_space > max_space
66
+
67
+ strict_nodes = strict && delta_nodes.positive? && max_nodes.positive?
68
+ raise Errno::ENOSPC if strict_nodes && nodes + delta_nodes > max_nodes
69
+
70
+ @nodes += delta_nodes
71
+ @space += delta_space
72
+ self
73
+ end
74
+
75
+ # Adjust for incremental write
76
+ # @param [Integer] current_size
77
+ # @param [Integer] data_size size of new data
78
+ # @param [Integer] offset offset of new data
79
+ # @return [self]
80
+ def write(current_size, data_size, offset, strict: true)
81
+ adjust(offset + data_size - current_size, strict: strict) if current_size < offset + data_size
82
+ self
83
+ end
84
+
85
+ # Adjust for truncate
86
+ # @param [Integer] current_size
87
+ # @param [Integer] new_size the size being truncated to
88
+ # @return [self]
89
+ def truncate(current_size, new_size)
90
+ adjust(new_size - current_size, strict: false) if new_size < current_size
91
+ self
92
+ end
93
+
94
+ # rubocop:disable Metrics/AbcSize
95
+
96
+ # @param [FFI::StatVfs] statvfs an existing statvfs buffer to fill
97
+ # @param [Integer] block_size
98
+ # @return [FFI::StatVfs] the filesystem statistics
99
+ def to_statvfs(statvfs = FFI::StatVfs.new, block_size: self.block_size || 1024)
100
+ used_blocks, max_blocks = [space, max_space].map { |s| s / block_size }
101
+ max_blocks = used_blocks - max_blocks unless max_blocks.positive?
102
+ max_files = max_nodes.positive? ? max_nodes : nodes - max_nodes
103
+ statvfs.bsize = block_size # block size (in Kb)
104
+ statvfs.frsize = block_size # fragment size pretty much always bsize
105
+ statvfs.blocks = max_blocks
106
+ statvfs.bfree = max_blocks - used_blocks
107
+ statvfs.bavail = max_blocks - used_blocks
108
+ statvfs.files = max_files
109
+ statvfs.ffree = max_files - nodes
110
+ statvfs
111
+ end
112
+ # rubocop:enable Metrics/AbcSize
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mapped_files'
4
+
5
+ module FFI
6
+ module Libfuse
7
+ module Filesystem
8
+ # A read-only directory of {MappedFiles}
9
+ #
10
+ # Subclasses must implement {#entries} and {#map_path}
11
+ class MappedDir
12
+ include MappedFiles
13
+ include Utils
14
+ attr_accessor :stat
15
+
16
+ # @!method entries
17
+ # @abstract
18
+ # @return [Enumerable] set of entries in this directory (excluding '.' and '..')
19
+
20
+ def initialize(accounting: nil)
21
+ @accounting = accounting
22
+ @root = VirtualNode.new(accounting: accounting)
23
+ end
24
+
25
+ # @!group Fuse Callbacks
26
+
27
+ # For the root path provides this directory's stat information, otherwise passes on to the next filesystem
28
+ def getattr(path, stat = nil, _ffi = nil)
29
+ return super unless root?(path)
30
+
31
+ stat&.directory(@root.virtual_stat.merge({ nlink: entries.size + 2 }))
32
+
33
+ self
34
+ end
35
+
36
+ # For root path enumerates {#entries}
37
+ # @raise [Errno::ENOTDIR] unless root path
38
+ def readdir(path, *_args, &block)
39
+ raise Errno::ENOTDIR unless root?(path)
40
+
41
+ %w[. ..].concat(entries).each(&block)
42
+ end
43
+
44
+ def mkdir(path, mode, *_args)
45
+ raise Errno::EROFS unless root?(path)
46
+
47
+ @root.init_node(mode)
48
+ end
49
+
50
+ # @!endgroup
51
+
52
+ # Passes FUSE Callbacks on to the {#root} filesystem
53
+ def method_missing(method, path = nil, *args, &block)
54
+ return @root.public_send(method, path, *args, &block) if @root.respond_to?(method) && root?(path)
55
+
56
+ raise Errno::ENOTSUP if FuseOperations.path_callbacks.include?(method)
57
+
58
+ super
59
+ end
60
+
61
+ def respond_to_missing?(method, private = false)
62
+ (FuseOperations.fuse_callbacks.include?(method) && @root.respond_to?(method, false)) || super
63
+ end
64
+
65
+ # subclass only call super for root path
66
+ def map_path(path)
67
+ raise ArgumentError, "map_path received non root path #{path}" unless root?(path)
68
+
69
+ [path, @root]
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../adapter'
4
+ require_relative 'accounting'
5
+
6
+ module FFI
7
+ module Libfuse
8
+ module Filesystem
9
+ # An abstract filesystem mapping paths to either real files or an alternate filesystem based on outcome of
10
+ # {#map_path} as implemented by including class
11
+ #
12
+ # Real files permissions are made read-only by default. Including classes can override {#stat_mask} to
13
+ # change this behaviour
14
+ #
15
+ # Implements callbacks satisfying {Adapter::Ruby} which is automatically included.
16
+ module MappedFiles
17
+ # Do we have ffi-xattr to handle extended attributes in real files
18
+ HAS_XATTR =
19
+ begin
20
+ require 'ffi-xattr'
21
+ true
22
+ rescue LoadError
23
+ false
24
+ end
25
+
26
+ # @return [Accounting|nil]
27
+ # if set the accounting object will be used to provide {#statfs} for the root path
28
+ # @note the real LIBC statvfs is always used for non-root paths
29
+ attr_accessor :accounting
30
+
31
+ # @!method map_path(path)
32
+ # @abstract
33
+ # @param [String] path the path in the fuse filesystem
34
+ # @return [String] mapped_path in an underlying filesystem
35
+ #
36
+ # Fuse callbacks are fulfilled using Ruby's native File methods called on this path
37
+ # @return [String, Adapter::Ruby::Prepend] mapped_path, filesystem
38
+ #
39
+ # If an optional filesystem value is returned fuse callbacks will be passed on to this filesystem with the
40
+ # mapped_path and other callback args unchanged
41
+ # @return [nil]
42
+ #
43
+ # eg on create to indicate the path does not exist
44
+
45
+ # Manipulate file attributes
46
+ #
47
+ # Default implementation forces read-only permissions
48
+ # @overload stat_mask(path,stat)
49
+ # @param [String] path the path received by {#getattr}
50
+ # @param [FFI::Stat] stat loaded from the mapped file, can be filled, mapped as necessary
51
+ # @return [FFI::Stat] stat
52
+ def stat_mask(_path, stat)
53
+ stat.mask(0o0222)
54
+ end
55
+
56
+ # @!group FUSE Callbacks
57
+
58
+ # Pass to real stat and then {#stat_mask}
59
+ def getattr(path, stat, ffi = nil)
60
+ if (fd = ffi&.fh&.fileno)
61
+ stat.fstat(fd)
62
+ else
63
+ path_method(__method__, path, stat, ffi) { |rp| stat.stat(rp) }
64
+ end
65
+
66
+ stat_mask(path, stat)
67
+ end
68
+
69
+ # Create real file - assuming the path can be mapped before it exists
70
+ def create(path, perms, ffi)
71
+ path_method(__method__, path, perms, ffi, error: Errno::EROFS) do |rp|
72
+ File.open(rp, ffi.flags, perms)
73
+ end
74
+ end
75
+
76
+ # @return [File] the newly opened file at {#map_path}(path)
77
+ def open(path, ffi)
78
+ path_method(__method__, path, ffi) { |rp| File.open(rp, ffi.flags) }
79
+ end
80
+
81
+ # Truncates the file handle (or the real file)
82
+ def truncate(path, size, ffi = nil)
83
+ return ffi.fh.truncate(size) if ffi&.fh
84
+
85
+ path_method(__method__, path, size, ffi) { |rp| File.truncate(rp, size) }
86
+ end
87
+
88
+ # Delete the real file
89
+ def unlink(path)
90
+ path_method(__method__, path) { |rp| File.unlink(rp) }
91
+ end
92
+
93
+ # Calls File.utime on an Integer file handle or the real file
94
+ def utimens(_path, atime, mtime, ffi = nil)
95
+ return File.utime(atime, mtime, ffi.fh) if ffi&.fh.is_a?(Integer)
96
+
97
+ path_method(__method__, atime, mtime, ffi) { |rp| File.utime(atime, mtime, rp) }
98
+ end
99
+
100
+ # @return [String] the value of the extended attribute name from the real file
101
+ def getxattr(path, name)
102
+ return nil unless HAS_XATTR
103
+
104
+ path_method(__method__, path, name) { |rp| Xattr.new(rp)[name] }
105
+ end
106
+
107
+ # @return [Array<String>] the list of extended attributes from the real file
108
+ def listxattr(path)
109
+ return [] unless HAS_XATTR
110
+
111
+ path_method(__method__, path) { |rp| Xattr.new(rp).list }
112
+ end
113
+
114
+ # TODO: Set xattr
115
+ # TODO: chmod, change the stat[:mode]
116
+ # TODO: chown, change the stat[:uid,:gid]
117
+
118
+ def statfs(path, statvfs)
119
+ return accounting&.to_statvfs(statvfs) if root?(path)
120
+
121
+ path_method(__method__, path, statvfs) { |rp| statvfs.from(rp) }
122
+ end
123
+ # @!endgroup
124
+
125
+ # @!visibility private
126
+ def self.included(mod)
127
+ mod.prepend(Adapter::Ruby::Prepend)
128
+ end
129
+
130
+ private
131
+
132
+ def path_method(callback, path, *args, error: Errno::ENOENT, block: nil)
133
+ rp, fs = map_path(path)
134
+ raise error if error && !rp
135
+
136
+ fs ? fs.send(callback, rp, *args, &block) : yield(rp)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mapped_files'
4
+
5
+ module FFI
6
+ module Libfuse
7
+ module Filesystem
8
+ # A Filesystem that maps paths to an underlying directory
9
+ class PassThroughDir
10
+ include MappedFiles
11
+ include Adapter::Debug
12
+ include Adapter::Safe
13
+ include Utils
14
+
15
+ # @return [String] The base directory
16
+ attr_accessor :base_dir
17
+
18
+ # @!group FUSE Callbacks
19
+
20
+ # @return [Dir] the directory at {#map_path}(path)
21
+ def opendir(path, _ffi)
22
+ Dir.new(map_path(path))
23
+ end
24
+
25
+ # Removes the directory at {#map_path}(path)
26
+ def rmdir(path)
27
+ return Dir.rmdir(map_path(path)) unless root?(path)
28
+
29
+ accounting&.adjust(0, -1) if root?(path)
30
+ self
31
+ end
32
+
33
+ # Creates the directory at {#map_path}(path)
34
+ def mkdir(path, mode)
35
+ return Dir.mkdir(map_path(path), mode) unless root?(path)
36
+
37
+ accounting&.adjust(0, +1)
38
+ self
39
+ end
40
+
41
+ # Creates the File at {#map_path}(path)
42
+ def create(path, perms = 0o644, ffi = nil)
43
+ File.open(map_path(path), ffi&.flags, perms)
44
+ end
45
+
46
+ # @!endgroup
47
+
48
+ # @return [String] {#base_dir} + path
49
+ def map_path(path)
50
+ root?(path) ? @base_dir : "#{@base_dir}#{path}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'accounting'
4
+ require_relative 'mapped_files'
5
+
6
+ module FFI
7
+ module Libfuse
8
+ module Filesystem
9
+ # Represents a single regular file at a given underlying path
10
+ class PassThroughFile
11
+ include MappedFiles
12
+
13
+ # @param [String] real_path
14
+ def initialize(real_path)
15
+ @real_path = real_path
16
+ end
17
+
18
+ # @!visibility private
19
+ def map_path(path)
20
+ raise Errno::ENOENT unless root?(path)
21
+
22
+ @real_path
23
+ end
24
+
25
+ # @!group FUSE Callbacks
26
+
27
+ # Adjust accounting to add a node
28
+ def create(path, perms, ffi)
29
+ raise Errno::ENOENT unless root?(path)
30
+
31
+ accounting&.adjust(0, +1)
32
+ super
33
+ end
34
+
35
+ # Adjust accounting to remove a node
36
+ def unlink(path)
37
+ raise Errno::ENOENT unless root?(path)
38
+
39
+ accounting&.adjust(0, -1)
40
+ super
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../fuse_context'
4
+
5
+ module FFI
6
+ module Libfuse
7
+ module Filesystem
8
+ # RubySpace File Utilities
9
+ #
10
+ # This module provides utility methods for file operations in RubySpace which are useful for creating custom
11
+ # entries prior to mounting, or otherwise manipulating the filesystem from within the Ruby process that is
12
+ # running FUSE.
13
+ #
14
+ # **Note** You cannot generally call Ruby's {::File} and {::Dir} operations from within the same Ruby process
15
+ # as the mounted filesystem because MRI will not release the GVL to allow the Fuse callbacks to run.
16
+ module Utils
17
+ # Recursive mkdir
18
+ # @param [:to_s] path
19
+ # @param [Integer] mode permissions for any dirs that need to be created
20
+ # @yieldparam [String] the path component being created
21
+ # @yieldreturn [FuseOperations] optionally a filesystem to mount at path, if the path did not previously exist
22
+ def mkdir_p(path, mode = (0o0777 & ~FuseContext.get.umask), &mount_fs)
23
+ return if root?(path) # nothing to make
24
+
25
+ path.to_s.split('/')[1..].inject('') do |base_path, sub_dir|
26
+ full_path = "#{base_path}/#{sub_dir}"
27
+ err = Adapter::Safe.safe_callback(:mkdir) { mkdir(full_path, mode, &mount_fs) }
28
+ unless [0, -Errno::EEXIST::Errno].include?(err)
29
+ raise SystemCallError.new("Unexpected err #{err.abs} from mkdir #{full_path}", err.abs)
30
+ end
31
+
32
+ full_path
33
+ end
34
+ 0
35
+ end
36
+ alias mkpath mkdir_p
37
+
38
+ # @param [:to_s] path
39
+ # @return [FFI::Stat]
40
+ def stat(path)
41
+ path = path.to_s
42
+ stat_buf = FFI::Stat.new
43
+
44
+ err = Adapter::Safe.safe_callback(:getattr) { getattr(path, stat_buf) }
45
+
46
+ return nil if err == -Errno::ENOENT::Errno
47
+ raise SystemCallError, "Unexpected error from #{fs}.getattr #{path}", err unless err.zero?
48
+
49
+ stat_buf
50
+ end
51
+
52
+ # @param [:to_s] path
53
+ # @return [Boolean] true if file or directory exists at path
54
+ def exists?(path)
55
+ stat(path) && true
56
+ end
57
+
58
+ # @param [:to_s] path
59
+ # @return [Boolean] true if regular file exists at path
60
+ def file?(path)
61
+ stat(path)&.file? || false
62
+ end
63
+
64
+ # @param [:to_s] path
65
+ # @return [Boolean] true if directory exists at path
66
+ def directory?(path)
67
+ stat(path)&.directory? || false
68
+ end
69
+
70
+ # @param [:to_s] path
71
+ # @return [Boolean] File exists at path and has zero size
72
+ def empty_file?(path)
73
+ s = stat(path)
74
+ (s&.file? && s.size.zero?) || false
75
+ end
76
+
77
+ # Check if a directory is empty
78
+ # @param [String] path
79
+ # @return [Boolean] true if an empty directory exists at path
80
+ # @raise [Errno::ENOTDIR] if path does not point to a directory
81
+ def empty_dir?(path)
82
+ return false unless directory?(path)
83
+
84
+ empty = true
85
+ fake_filler = proc do |_buf, name, _stat = nil, _offset = 0, _fuse_flag = 0|
86
+ next 0 if %w[. ..].include?(name)
87
+
88
+ empty = false
89
+ -1 # buf full don't send more entries!
90
+ end
91
+ readdir(path.to_s, nil, fake_filler, 0, nil, *(fuse3_compat? ? [] : [0]))
92
+ empty
93
+ end
94
+
95
+ # @!visibility private
96
+ def fuse3_compat?
97
+ FUSE_MAJOR_VERSION >= 3
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end