diff --git a/lib/Test/MockFile.pm b/lib/Test/MockFile.pm index 6c5d4cf..c234826 100644 --- a/lib/Test/MockFile.pm +++ b/lib/Test/MockFile.pm @@ -85,6 +85,7 @@ use constant S_IFBLK => 0060000; # block device use constant S_IFDIR => 0040000; # directory use constant S_IFCHR => 0020000; # character device use constant S_IFIFO => 0010000; # FIFO +use constant S_ISVTX => 0001000; # sticky bit =head1 SYNOPSIS @@ -783,6 +784,33 @@ sub _check_parent_perms { return _check_perms( $parent_mock, $access ); } +# _check_sticky_bit($parent_path, $file_path) +# Enforces sticky bit restriction on a directory. +# When a directory has the sticky bit (S_ISVTX / 01000) set, only the file +# owner, the directory owner, or root may remove/rename entries. +# Returns 1 if allowed, 0 if denied. +sub _check_sticky_bit { + my ( $parent_path, $file_path ) = @_; + + return 1 unless defined $_mock_uid; + return 1 if $_mock_uid == 0; # root bypasses sticky bit + + my $parent_mock = _get_file_object($parent_path); + return 1 unless $parent_mock; + + # Only applies when sticky bit is set on the parent directory + return 1 unless $parent_mock->{'mode'} & S_ISVTX; + + my $file_mock = _get_file_object($file_path); + return 1 unless $file_mock; + + # Allowed if user owns the file or owns the directory + return 1 if $_mock_uid == $file_mock->{'uid'}; + return 1 if $_mock_uid == $parent_mock->{'uid'}; + + return 0; +} + my @_tmf_callers; # Packages where autodie was active when T::MF was imported. @@ -3680,6 +3708,16 @@ sub __unlink (@) { $! = EACCES; next; } + + # Sticky bit: only file owner, dir owner, or root may remove + if ( defined $_mock_uid ) { + ( my $parent = $mock->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $parent = '/' if $parent eq ''; + if ( !_check_sticky_bit( $parent, $mock->{'path'} ) ) { + $! = EACCES; + next; + } + } $files_deleted += $mock->unlink; } } @@ -3993,6 +4031,17 @@ sub __rmdir (_) { return 0; } + # Sticky bit: only dir owner, parent dir owner, or root may remove + if ( defined $_mock_uid ) { + ( my $parent = $mock->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $parent = '/' if $parent eq ''; + if ( !_check_sticky_bit( $parent, $mock->{'path'} ) ) { + $! = EACCES; + _maybe_throw_autodie( 'rmdir', @_ ); + return 0; + } + } + if ( grep { $_->exists } _files_in_dir($file) ) { $! = ENOTEMPTY; _maybe_throw_autodie( 'rmdir', @_ ); @@ -4041,6 +4090,28 @@ sub __rename ($$) { # Renaming to self is a no-op (POSIX rename(2)) return 1 if $mock_old == $mock_new; + # Sticky bit: source parent dir sticky bit restricts who can move the file + if ( defined $_mock_uid ) { + ( my $old_parent = $mock_old->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $old_parent = '/' if $old_parent eq ''; + if ( !_check_sticky_bit( $old_parent, $mock_old->{'path'} ) ) { + $! = EACCES; + _maybe_throw_autodie( 'rename', @_ ); + return 0; + } + + # If destination exists and its parent has sticky bit, check that too + if ( $mock_new->exists ) { + ( my $new_parent = $mock_new->{'path'} ) =~ s{ / [^/]+ $ }{}xms; + $new_parent = '/' if $new_parent eq ''; + if ( !_check_sticky_bit( $new_parent, $mock_new->{'path'} ) ) { + $! = EACCES; + _maybe_throw_autodie( 'rename', @_ ); + return 0; + } + } + } + # Can't overwrite a directory with a non-directory if ( $mock_new->exists && $mock_new->is_dir && !$mock_old->is_dir ) { $! = EISDIR; diff --git a/t/sticky_bit.t b/t/sticky_bit.t new file mode 100644 index 0000000..4d4d0f4 --- /dev/null +++ b/t/sticky_bit.t @@ -0,0 +1,164 @@ +use strict; +use warnings; + +use Test2::Bundle::Extended; +use Test2::Tools::Explain; +use Test2::Plugin::NoWarnings; + +use Errno qw( EACCES ); +use Fcntl qw( S_IFDIR ); + +use Test::MockFile qw< nostrict >; + +# POSIX sticky bit (S_ISVTX / 01000) enforcement: +# When a directory has the sticky bit set, only the file owner, +# the directory owner, or root may delete/rename entries in it. +# Classic example: /tmp (mode 1777). + +# ========================================================================= +# Helpers +# ========================================================================= + +sub with_user (&@) { + my ( $code, $uid, @gids ) = @_; + Test::MockFile->set_user( $uid, @gids ); + my $ok = eval { $code->(); 1 }; + my $err = $@; + Test::MockFile->clear_user(); + die $err unless $ok; +} + +# new_dir applies umask, so we override mode directly after creation +sub make_dir { + my ( $path, %opts ) = @_; + my $mode = delete $opts{mode} // 0755; + my $uid = delete $opts{uid} // 0; + my $gid = delete $opts{gid} // 0; + my $dir = Test::MockFile->new_dir( $path, {} ); + $dir->{'mode'} = $mode | S_IFDIR; + $dir->{'uid'} = $uid; + $dir->{'gid'} = $gid; + return $dir; +} + +# ========================================================================= +# unlink in sticky directory +# ========================================================================= + +subtest 'unlink: sticky bit blocks non-owner deletion' => sub { + my $dir = make_dir( '/sticky', mode => 01777, uid => 0, gid => 0 ); + my $file = Test::MockFile->file( '/sticky/owned_by_1000', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( !unlink('/sticky/owned_by_1000'), 'unlink fails for non-owner in sticky dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'unlink: file owner can delete in sticky dir' => sub { + my $dir = make_dir( '/sticky_owner', mode => 01777, uid => 0, gid => 0 ); + my $file = Test::MockFile->file( '/sticky_owner/myfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/sticky_owner/myfile'), 'file owner can unlink in sticky dir' ); + } 1000, 1000; +}; + +subtest 'unlink: directory owner can delete in sticky dir' => sub { + my $dir = make_dir( '/sticky_dirowner', mode => 01777, uid => 500, gid => 500 ); + my $file = Test::MockFile->file( '/sticky_dirowner/otherfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/sticky_dirowner/otherfile'), 'dir owner can unlink in sticky dir' ); + } 500, 500; +}; + +subtest 'unlink: root bypasses sticky bit' => sub { + my $dir = make_dir( '/sticky_root', mode => 01777, uid => 500, gid => 500 ); + my $file = Test::MockFile->file( '/sticky_root/rootfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/sticky_root/rootfile'), 'root can unlink in sticky dir' ); + } 0, 0; +}; + +subtest 'unlink: non-sticky directory allows any writer to delete' => sub { + my $dir = make_dir( '/nonsticky', mode => 0777, uid => 0, gid => 0 ); + my $file = Test::MockFile->file( '/nonsticky/anyfile', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( unlink('/nonsticky/anyfile'), 'any user can unlink in non-sticky writable dir' ); + } 2000, 2000; +}; + +# ========================================================================= +# rmdir in sticky directory +# ========================================================================= + +subtest 'rmdir: sticky bit blocks non-owner removal' => sub { + my $parent = make_dir( '/sticky_rmdir', mode => 01777, uid => 0, gid => 0 ); + my $child = Test::MockFile->new_dir( '/sticky_rmdir/subdir', { mode => 0755, uid => 1000, gid => 1000 } ); + + with_user { + ok( !rmdir('/sticky_rmdir/subdir'), 'rmdir fails for non-owner in sticky dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'rmdir: entry owner can remove in sticky parent' => sub { + my $parent = make_dir( '/sticky_rmdir2', mode => 01777, uid => 0, gid => 0 ); + my $child = Test::MockFile->new_dir( '/sticky_rmdir2/subdir', { mode => 0755, uid => 1000, gid => 1000 } ); + + with_user { + ok( rmdir('/sticky_rmdir2/subdir'), 'entry owner can rmdir in sticky dir' ); + } 1000, 1000; +}; + +# ========================================================================= +# rename in sticky directory +# ========================================================================= + +subtest 'rename: sticky bit blocks non-owner rename (source)' => sub { + my $dir = make_dir( '/sticky_ren', mode => 01777, uid => 0, gid => 0 ); + my $src = Test::MockFile->file( '/sticky_ren/src', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + my $dst = Test::MockFile->file( '/sticky_ren/dst', undef ); + + with_user { + ok( !rename( '/sticky_ren/src', '/sticky_ren/dst' ), 'rename fails for non-owner in sticky dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'rename: file owner can rename in sticky dir' => sub { + my $dir = make_dir( '/sticky_ren2', mode => 01777, uid => 0, gid => 0 ); + my $src = Test::MockFile->file( '/sticky_ren2/src', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + my $dst = Test::MockFile->file( '/sticky_ren2/dst', undef ); + + with_user { + ok( rename( '/sticky_ren2/src', '/sticky_ren2/dst' ), 'file owner can rename in sticky dir' ); + } 1000, 1000; +}; + +subtest 'rename: sticky bit on destination blocks overwrite by non-owner' => sub { + my $srcdir = make_dir( '/ren_src', mode => 0777, uid => 0, gid => 0 ); + my $dstdir = make_dir( '/ren_dst', mode => 01777, uid => 0, gid => 0 ); + my $src = Test::MockFile->file( '/ren_src/file', 'new', { mode => 0644, uid => 2000, gid => 2000 } ); + my $dst = Test::MockFile->file( '/ren_dst/file', 'old', { mode => 0644, uid => 1000, gid => 1000 } ); + + with_user { + ok( !rename( '/ren_src/file', '/ren_dst/file' ), 'rename blocked by sticky bit on destination dir' ); + is( $! + 0, EACCES, 'errno is EACCES' ); + } 2000, 2000; +}; + +subtest 'rename: root bypasses sticky bit' => sub { + my $dir = make_dir( '/sticky_ren3', mode => 01777, uid => 500, gid => 500 ); + my $src = Test::MockFile->file( '/sticky_ren3/src', 'data', { mode => 0644, uid => 1000, gid => 1000 } ); + my $dst = Test::MockFile->file( '/sticky_ren3/dst', undef ); + + with_user { + ok( rename( '/sticky_ren3/src', '/sticky_ren3/dst' ), 'root can rename in sticky dir' ); + } 0, 0; +}; + +done_testing();