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]
This commit is contained in:
Jeremy Evans 2023-01-16 13:29:43 -08:00
parent 5d6579bd91
commit 466ca7ae20
Notes: git 2023-03-24 18:19:20 +00:00
4 changed files with 215 additions and 0 deletions

View file

@ -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

View file

@ -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)

130
dir.c
View file

@ -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);

View file

@ -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