From e4689c4e189129c4ffcbf86af8129dbfe41ca641 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Tue, 24 Mar 2026 07:26:30 +0000 Subject: [PATCH] fix: intercept chdir() for mocked directories (GH #312) Override CORE::GLOBAL::chdir to track a virtual CWD when the target is a mocked directory. Cwd::getcwd/cwd/fastcwd return the virtual path while active. Relative path resolution in _abs_path_to_file and __cwd_abs_path uses the virtual CWD. Falls through to CORE::chdir for non-mocked paths, which also clears the virtual CWD. The virtual CWD is automatically cleared when the backing mock object is destroyed. Closes #312 Co-Authored-By: Claude Opus 4.6 --- MANIFEST | 1 + lib/Test/MockFile.pm | 122 ++++++++++++++++++++++++++++++++++++++++-- t/chdir.t | 123 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 t/chdir.t diff --git a/MANIFEST b/MANIFEST index 0618c7e..67d6837 100644 --- a/MANIFEST +++ b/MANIFEST @@ -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 diff --git a/lib/Test/MockFile.pm b/lib/Test/MockFile.pm index 17aed44..f5c5432 100644 --- a/lib/Test/MockFile.pm +++ b/lib/Test/MockFile.pm @@ -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; @@ -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 @@ -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 ); @@ -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; @@ -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}; @@ -2646,8 +2667,17 @@ B's related functions. =item * closedir +=item * chdir + =back +B to a mocked directory sets a virtual current working directory. +While active, relative path resolution and C/C +return the virtual path. A subsequent C to a real (non-mocked) +directory clears the virtual CWD and falls through to C. +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. @@ -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 ) = @_; @@ -4347,6 +4455,7 @@ 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; @@ -4354,13 +4463,20 @@ BEGIN { *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. diff --git a/t/chdir.t b/t/chdir.t new file mode 100644 index 0000000..cdba6eb --- /dev/null +++ b/t/chdir.t @@ -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();