summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeremy Evans <code@jeremyevans.net>2023-01-16 13:29:43 -0800
committerJeremy Evans <code@jeremyevans.net>2023-03-24 11:18:57 -0700
commit466ca7ae205126c7cac83735db887d69e293f816 (patch)
tree2d2598ac52e1853f6afb8ddd0b1337616fd2647d
parent5d6579bd9129cfbd62702fb42b249338807a34a2 (diff)
Add Dir.fchdir
This is useful for passing directory file descriptors over UNIX sockets or to child processes to avoid TOCTOU vulnerabilities. The implementation follows the Dir.chdir code. This will raise NotImplementedError on platforms not supporting both fchdir and dirfd. Implements [Feature #19347]
Notes
Notes: Merged: https://github.com/ruby/ruby/pull/7135
-rw-r--r--NEWS.md6
-rw-r--r--configure.ac1
-rw-r--r--dir.c130
-rw-r--r--spec/ruby/core/dir/fchdir_spec.rb78
4 files changed, 215 insertions, 0 deletions
diff --git a/NEWS.md b/NEWS.md
index 53a7f2e723..c3118976bf 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -15,6 +15,11 @@ Note: We're only listing outstanding class updates.
* `Array#pack` now raises ArgumentError for unknown directives. [[Bug #19150]]
+* Dir
+
+ * `Dir.fchdir` added for changing the directory to the directory specified
+ by the provided directory file descriptor. [[Feature #19347]]
+
* String
* `String#unpack` now raises ArgumentError for unknown directives. [[Bug #19150]]
@@ -65,3 +70,4 @@ changelog for details of the default gems or bundled gems.
[Bug #19150]: https://bugs.ruby-lang.org/issues/19150
[Feature #19314]: https://bugs.ruby-lang.org/issues/19314
+[Feature #19347]: https://bugs.ruby-lang.org/issues/19347
diff --git a/configure.ac b/configure.ac
index 11c7b99f16..a0fadaa856 100644
--- a/configure.ac
+++ b/configure.ac
@@ -2018,6 +2018,7 @@ AC_CHECK_FUNCS(execv)
AC_CHECK_FUNCS(execve)
AC_CHECK_FUNCS(explicit_memset)
AC_CHECK_FUNCS(fcopyfile)
+AC_CHECK_FUNCS(fchdir)
AC_CHECK_FUNCS(fchmod)
AC_CHECK_FUNCS(fchown)
AC_CHECK_FUNCS(fcntl)
diff --git a/dir.c b/dir.c
index 20429e1516..c32e9de415 100644
--- a/dir.c
+++ b/dir.c
@@ -1094,6 +1094,135 @@ dir_s_chdir(int argc, VALUE *argv, VALUE obj)
return INT2FIX(0);
}
+#if defined(HAVE_FCHDIR) && defined(HAVE_DIRFD) && HAVE_FCHDIR && HAVE_DIRFD
+static void *
+nogvl_fchdir(void *ptr)
+{
+ const int *fd = ptr;
+
+ return (void *)(VALUE)fchdir(*fd);
+}
+
+static void
+dir_fchdir(int fd)
+{
+ if (fchdir(fd) < 0)
+ rb_sys_fail("fchdir");
+}
+
+struct fchdir_data {
+ VALUE old_dir;
+ int fd;
+ int done;
+};
+
+static VALUE
+fchdir_yield(VALUE v)
+{
+ struct fchdir_data *args = (void *)v;
+ dir_fchdir(args->fd);
+ args->done = TRUE;
+ chdir_blocking++;
+ if (NIL_P(chdir_thread))
+ chdir_thread = rb_thread_current();
+ return rb_yield_values(0);
+}
+
+static VALUE
+fchdir_restore(VALUE v)
+{
+ struct fchdir_data *args = (void *)v;
+ if (args->done) {
+ chdir_blocking--;
+ if (chdir_blocking == 0)
+ chdir_thread = Qnil;
+ dir_fchdir(RB_NUM2INT(dir_fileno(args->old_dir)));
+ }
+ dir_close(args->old_dir);
+ return Qnil;
+}
+
+/*
+ * call-seq:
+ * Dir.fchdir( integer ) -> 0
+ * Dir.fchdir( integer ) { block } -> anObject
+ *
+ * Changes the current working directory of the process to the directory
+ * specified by the given file descriptor integer. If the file descriptor
+ * is not valid, raises SystemCallError. One reason to use
+ * <code>fchdir</code> instead of <code>chdir</code> is when passing
+ * directory file descriptors over a UNIX socket or to child processes,
+ * to avoid TOCTOU (time-of-check to time-of-use) vulnerabilities.
+ *
+ * If a block is given, the current working directory is changed for the
+ * duration of the block, and the original working directory is restored
+ * when the block exits. The return value of <code>fchdir</code> is the
+ * value of the block. <code>fchdir</code> and <code>chdir</code> blocks
+ * can be nested, but in a multi-threaded program an error will be raised
+ * if a thread attempts to open a <code>fchdir</code> or <code>chdir</code>
+ * block while another thread has one open or a call to <code>fchdir</code>
+ * or <code>chdir</code> without a block occurs inside a block passed to
+ * <code>fchdir</code> or <code>chdir</code> (even in the same thread).
+ *
+ * When generating directory file descriptors from a +Dir+ instance,
+ * make sure the +Dir+ instance is not garbage collected before the
+ * directory file descriptor is passed to another process. Otherwise,
+ * the directory file descriptor will be closed before it is passed.
+ *
+ * dir = Dir.new("/var/spool/mail")
+ * dir2 = Dir.new("/usr")
+ * fd = dir.fileno
+ * fd2 = dir2.fileno
+ * Dir.fchdir(fd) do
+ * puts Dir.pwd
+ * Dir.fchdir(fd2) do
+ * puts Dir.pwd
+ * end
+ * puts Dir.pwd
+ * end
+ * puts Dir.pwd
+ *
+ * <em>produces:</em>
+ *
+ * /var/spool/mail
+ * /tmp
+ * /usr
+ * /tmp
+ * /var/spool/mail
+ */
+static VALUE
+dir_s_fchdir(VALUE klass, VALUE fd_value)
+{
+ int fd = RB_NUM2INT(fd_value);
+
+ if (chdir_blocking > 0) {
+ if (rb_thread_current() != chdir_thread)
+ rb_raise(rb_eRuntimeError, "conflicting chdir during another chdir block");
+ if (!rb_block_given_p())
+ rb_warn("conflicting chdir during another chdir block");
+ }
+
+ if (rb_block_given_p()) {
+ struct fchdir_data args;
+ args.old_dir = dir_s_alloc(klass);
+ dir_initialize(NULL, args.old_dir, rb_fstring_cstr("."), Qnil);
+ args.fd = fd;
+ args.done = FALSE;
+ return rb_ensure(fchdir_yield, (VALUE)&args, fchdir_restore, (VALUE)&args);
+ }
+ else {
+ int r = (int)(VALUE)rb_thread_call_without_gvl(nogvl_fchdir, &fd,
+ RUBY_UBF_IO, 0);
+ if (r < 0)
+ rb_sys_fail("fchdir");
+ }
+
+ return INT2FIX(0);
+}
+#else
+#define dir_s_fchdir rb_f_notimplement
+#endif
+
#ifndef _WIN32
VALUE
rb_dir_getwd_ospath(void)
@@ -3374,6 +3503,7 @@ Init_Dir(void)
rb_define_method(rb_cDir,"pos=", dir_set_pos, 1);
rb_define_method(rb_cDir,"close", dir_close, 0);
+ rb_define_singleton_method(rb_cDir,"fchdir", dir_s_fchdir, 1);
rb_define_singleton_method(rb_cDir,"chdir", dir_s_chdir, -1);
rb_define_singleton_method(rb_cDir,"getwd", dir_s_getwd, 0);
rb_define_singleton_method(rb_cDir,"pwd", dir_s_getwd, 0);
diff --git a/spec/ruby/core/dir/fchdir_spec.rb b/spec/ruby/core/dir/fchdir_spec.rb
new file mode 100644
index 0000000000..dde459e98e
--- /dev/null
+++ b/spec/ruby/core/dir/fchdir_spec.rb
@@ -0,0 +1,78 @@
+require_relative '../../spec_helper'
+require_relative 'fixtures/common'
+
+ruby_version_is '3.3' do
+ has_fchdir = begin
+ dir = Dir.new('.')
+ Dir.fchdir(dir.fileno)
+ true
+ rescue NotImplementedError
+ false
+ rescue Exception
+ true
+ ensure
+ dir.close
+ end
+
+ if has_fchdir
+ describe "Dir.fchdir" do
+ before :all do
+ DirSpecs.create_mock_dirs
+ end
+
+ after :all do
+ DirSpecs.delete_mock_dirs
+ end
+
+ before :each do
+ @dirs = [Dir.new('.')]
+ @original = @dirs.first.fileno
+ end
+
+ after :each do
+ Dir.fchdir(@original)
+ @dirs.each(&:close)
+ end
+
+ it "changes to the specified directory" do
+ dir = Dir.new(DirSpecs.mock_dir)
+ @dirs << dir
+ Dir.fchdir dir.fileno
+ Dir.pwd.should == DirSpecs.mock_dir
+ end
+
+ it "returns 0 when successfully changing directory" do
+ Dir.fchdir(@original).should == 0
+ end
+
+ it "returns the value of the block when a block is given" do
+ Dir.fchdir(@original) { :block_value }.should == :block_value
+ end
+
+ it "changes to the specified directory for the duration of the block" do
+ pwd = Dir.pwd
+ dir = Dir.new(DirSpecs.mock_dir)
+ @dirs << dir
+ Dir.fchdir(dir.fileno) { Dir.pwd }.should == DirSpecs.mock_dir
+ Dir.pwd.should == pwd
+ end
+
+ it "raises a SystemCallError if the file descriptor given is not valid" do
+ -> { Dir.fchdir -1 }.should raise_error(SystemCallError)
+ -> { Dir.fchdir(-1) { } }.should raise_error(SystemCallError)
+ end
+
+ it "raises a SystemCallError if the file descriptor given is not for a directory" do
+ -> { Dir.fchdir $stdout.fileno }.should raise_error(SystemCallError)
+ -> { Dir.fchdir($stdout.fileno) { } }.should raise_error(SystemCallError)
+ end
+ end
+ else
+ describe "Dir.fchdir" do
+ it "raises NotImplementedError" do
+ -> { Dir.fchdir 1 }.should raise_error(NotImplementedError)
+ -> { Dir.fchdir(1) { } }.should raise_error(NotImplementedError)
+ end
+ end
+ end
+end