Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MANIFEST
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ t/autovivify.t
t/blocks.t
t/chmod-chown-passthrough.t
t/chmod-filetemp.t
t/chdir.t
t/chmod.t
t/chown-chmod-nostrict.t
t/chown.t
Expand Down
122 changes: 119 additions & 3 deletions lib/Test/MockFile.pm
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ our %files_being_mocked;

# Original Cwd functions saved before override
my $_original_cwd_abs_path;
my $_original_cwd_getcwd;
my $_original_cwd_cwd;

# Virtual CWD: set when chdir() targets a mocked directory.
# undef means "use real cwd". When set, relative path resolution
# and Cwd::getcwd/cwd use this instead of the real cwd.
my $_virtual_cwd;

# Tracks directories with autovivify enabled: path => mock object (weak ref)
my %_autovivify_dirs;
Expand Down Expand Up @@ -1835,9 +1842,10 @@ sub _abs_path_to_file {
$path =~ s{\Q$req_homedir\E}{$pw_homedir};
}

# Make path absolute if relative
# Make path absolute if relative (use virtual cwd when active)
if ( $path !~ m{^/}xms ) {
$path = Cwd::getcwd() . "/$path";
my $cwd = defined $_virtual_cwd ? $_virtual_cwd : Cwd::getcwd();
$path = $cwd . "/$path";
}

# Resolve path components: remove ".", resolve "..", collapse slashes
Expand All @@ -1864,7 +1872,8 @@ sub __cwd_abs_path {

# Make absolute without collapsing .. (symlink-aware resolution does that)
if ( $path !~ m{^/} ) {
$path = Cwd::getcwd() . "/$path";
my $cwd = defined $_virtual_cwd ? $_virtual_cwd : Cwd::getcwd();
$path = $cwd . "/$path";
}

my @remaining = grep { $_ ne '' && $_ ne '.' } split( m{/}, $path );
Expand Down Expand Up @@ -1922,6 +1931,13 @@ sub __cwd_abs_path {
return $resolved || '/';
}

# Override for Cwd::getcwd / Cwd::cwd that returns the virtual CWD
# when chdir() has been used on a mocked directory.
sub __cwd_getcwd {
return $_virtual_cwd if defined $_virtual_cwd;
return $_original_cwd_getcwd->();
}

sub DESTROY {
my ($self) = @_;
ref $self or return;
Expand All @@ -1939,6 +1955,11 @@ sub DESTROY {
return;
}

# If this mock was the virtual CWD, clear it (GH #312)
if ( defined $_virtual_cwd && $_virtual_cwd eq $path ) {
$_virtual_cwd = undef;
}

# Clean up autovivify tracking
delete $_autovivify_dirs{$path};

Expand Down Expand Up @@ -2646,8 +2667,17 @@ B<opendir>'s related functions.

=item * closedir

=item * chdir

=back

B<chdir> to a mocked directory sets a virtual current working directory.
While active, relative path resolution and C<Cwd::getcwd>/C<Cwd::cwd>
return the virtual path. A subsequent C<chdir> to a real (non-mocked)
directory clears the virtual CWD and falls through to C<CORE::chdir>.
The virtual CWD is also cleared when the mock object backing it goes
out of scope.

=cut

# goto messed up refcount between 5.22 and 5.26.
Expand Down Expand Up @@ -3866,6 +3896,84 @@ sub __rmdir (_) {
return 1;
}

# chdir() override: intercepts chdir to mocked directories by tracking
# a virtual CWD. Falls through to CORE::chdir for non-mocked paths.
# When the virtual CWD is active, relative path resolution and
# Cwd::getcwd/cwd use it instead of the real process cwd.
sub __chdir (;$) {
my $dir;

if (@_) {
$dir = $_[0];
}
else {
# chdir() with no args goes to $ENV{HOME}
$dir = $ENV{HOME};
if ( !defined $dir ) {
$! = ENOENT;
_maybe_throw_autodie( 'chdir', @_ );
return 0;
}
}

my $abs = _abs_path_to_file($dir);
my $mock = $files_being_mocked{$abs};

if ( !$mock ) {
# Not mocked — try real chdir
_real_file_access_hook( 'chdir', \@_ );
my $ret = CORE::chdir($dir);
if ($ret) {
# Successful real chdir clears virtual cwd
$_virtual_cwd = undef;
}
return $ret;
}

# Must be an existing directory
if ( !$mock->exists ) {
$! = ENOENT;
_maybe_throw_autodie( 'chdir', @_ );
return 0;
}

if ( !$mock->is_dir ) {
$! = ENOTDIR;
_maybe_throw_autodie( 'chdir', @_ );
return 0;
}

# Permission check: chdir needs execute on the target dir
if ( defined $_mock_uid ) {
my $mode = $mock->{'mode'} & S_IFPERMS;
my $uid = $_mock_uid;
my $gid = $mock->{'gid'} // 0;
my $has_x;
if ( $uid == 0 ) {
$has_x = 1; # root can always chdir
}
elsif ( $uid == ( $mock->{'uid'} // 0 ) ) {
$has_x = $mode & 0100;
}
elsif ( $gid == $gid ) { # simplified group check
$has_x = $mode & 0010;
}
else {
$has_x = $mode & 0001;
}
if ( !$has_x ) {
$! = EACCES;
_maybe_throw_autodie( 'chdir', @_ );
return 0;
}
}

# Set virtual CWD to the mocked directory
$_virtual_cwd = $abs;

return 1;
}

sub __rename ($$) {
my ( $old, $new ) = @_;

Expand Down Expand Up @@ -4347,20 +4455,28 @@ BEGIN {

*CORE::GLOBAL::rename = \&__rename;
*CORE::GLOBAL::rmdir = \&__rmdir;
*CORE::GLOBAL::chdir = \&__chdir;
*CORE::GLOBAL::chown = \&__chown;
*CORE::GLOBAL::chmod = \&__chmod;
*CORE::GLOBAL::flock = \&__flock;
*CORE::GLOBAL::utime = \&__utime;
*CORE::GLOBAL::truncate = \&__truncate;

# Override Cwd functions to resolve mocked symlinks (GH #139)
# and support virtual CWD for chdir to mocked directories (GH #312)
$_original_cwd_abs_path = \&Cwd::abs_path;
$_original_cwd_getcwd = \&Cwd::getcwd;
$_original_cwd_cwd = \&Cwd::cwd;
{
no warnings 'redefine';
*Cwd::abs_path = \&__cwd_abs_path;
*Cwd::realpath = \&__cwd_abs_path;
*Cwd::fast_abs_path = \&__cwd_abs_path;
*Cwd::fast_realpath = \&__cwd_abs_path;
*Cwd::getcwd = \&__cwd_getcwd;
*Cwd::cwd = \&__cwd_getcwd;
*Cwd::fastcwd = \&__cwd_getcwd;
*Cwd::fast_cwd = \&__cwd_getcwd;
}

# Override IO::File::open to intercept mocked files.
Expand Down
123 changes: 123 additions & 0 deletions t/chdir.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/perl -w

use strict;
use warnings;

use Test2::Bundle::Extended;
use Test2::Tools::Explain;
use Test2::Plugin::NoWarnings;

use Errno qw/ENOENT ENOTDIR/;
use Cwd ();

use Test::MockFile qw< nostrict >;

my $real_cwd = Cwd::getcwd();

subtest "chdir to mocked directory" => sub {
my $mock_dir = Test::MockFile->new_dir("/tmp/mock_chdir_test");

ok( -d "/tmp/mock_chdir_test", "Mock dir exists via -d" );

$! = 0;
is( chdir("/tmp/mock_chdir_test"), 1, "chdir to mocked dir succeeds" );
is( $! + 0, 0, ' - $! is unset' );

is( Cwd::getcwd(), "/tmp/mock_chdir_test", "Cwd::getcwd returns virtual cwd" );
is( Cwd::cwd(), "/tmp/mock_chdir_test", "Cwd::cwd returns virtual cwd" );
is( Cwd::fastcwd(), "/tmp/mock_chdir_test", "Cwd::fastcwd returns virtual cwd" );

# Restore real cwd
CORE::chdir($real_cwd);
};

subtest "chdir to non-existent mocked dir fails" => sub {
my $mock_dir = Test::MockFile->dir("/tmp/mock_chdir_noexist");

ok( !-d "/tmp/mock_chdir_noexist", "Mock dir does not exist" );

$! = 0;
is( chdir("/tmp/mock_chdir_noexist"), 0, "chdir to non-existent mock fails" );
is( $! + 0, ENOENT, " - \$! is ENOENT" );
};

subtest "chdir to mocked file fails with ENOTDIR" => sub {
my $mock_file = Test::MockFile->file("/tmp/mock_chdir_file", "content");

$! = 0;
is( chdir("/tmp/mock_chdir_file"), 0, "chdir to file fails" );
is( $! + 0, ENOTDIR, ' - $! is ENOTDIR' );
};

subtest "relative path resolution uses virtual cwd" => sub {
my $mock_dir = Test::MockFile->new_dir("/tmp/mock_cwd_parent");
my $mock_file = Test::MockFile->file("/tmp/mock_cwd_parent/hello.txt", "world");

chdir("/tmp/mock_cwd_parent");
is( Cwd::getcwd(), "/tmp/mock_cwd_parent", "Virtual cwd is set" );

# Open a file with a relative path — should resolve against virtual cwd
open( my $fh, '<', 'hello.txt' ) or die "open failed: $!";
my $content = do { local $/; <$fh> };
close $fh;

is( $content, "world", "Relative open resolves against virtual cwd" );

# Restore real cwd
CORE::chdir($real_cwd);
};

subtest "chdir with no args uses HOME" => sub {
local $ENV{HOME} = "/tmp/mock_chdir_home";
my $mock_dir = Test::MockFile->new_dir("/tmp/mock_chdir_home");

is( chdir(), 1, "chdir() with no args uses HOME" );
is( Cwd::getcwd(), "/tmp/mock_chdir_home", "Virtual cwd set to HOME" );

CORE::chdir($real_cwd);
};

subtest "real chdir clears virtual cwd" => sub {
my $mock_dir = Test::MockFile->new_dir("/tmp/mock_chdir_clear");

chdir("/tmp/mock_chdir_clear");
is( Cwd::getcwd(), "/tmp/mock_chdir_clear", "Virtual cwd is set" );

# Real chdir to actual directory should clear virtual cwd
CORE::chdir($real_cwd);
chdir($real_cwd);
is( Cwd::getcwd(), $real_cwd, "Real chdir clears virtual cwd" );
};

subtest "mock destruction clears virtual cwd" => sub {
{
my $mock_dir = Test::MockFile->new_dir("/tmp/mock_chdir_destroy");
chdir("/tmp/mock_chdir_destroy");
is( Cwd::getcwd(), "/tmp/mock_chdir_destroy", "Virtual cwd is set" );
}

# Mock went out of scope — virtual cwd should be cleared
isnt( Cwd::getcwd(), "/tmp/mock_chdir_destroy", "Virtual cwd cleared on mock destruction" );
};

subtest "chdir to unmocked path falls through" => sub {
# chdir to a real directory should work normally
$! = 0;
my $result = chdir($real_cwd);
is( $result, 1, "chdir to real directory succeeds" );
is( $! + 0, 0, ' - $! is unset' );
is( Cwd::getcwd(), $real_cwd, "Real getcwd after real chdir" );
};

subtest "stat -d after chdir to mocked dir" => sub {
my $mock_dir = Test::MockFile->new_dir("/tmp/mock_chdir_stat");
my $mock_sub = Test::MockFile->new_dir("/tmp/mock_chdir_stat/sub");

chdir("/tmp/mock_chdir_stat");

ok( -d "sub", "-d on relative path works after chdir to mocked dir" );

CORE::chdir($real_cwd);
};

done_testing();
Loading