Make WordPress Core

Opened 6 weeks ago

Last modified 3 weeks ago

#65150 new defect (bug)

Issue with copy_dir() function t does not skips folders.

Reported by: neo2k23's profile neo2k23 Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version:
Component: Filesystem API Keywords: has-patch
Focuses: Cc:

Description

I have a issue with the copy_dir() function. If i want to skip a folder called 'languages' it does not skip it and the files within that folder are still copied.

$skip_list = array('languages');
$result = copy_dir($from, $to, $skip_list);

However if i revert copy_dir to the version which was in WordPress 3.2.1 it works just fine. Below is the code from WordPress version 3.2.1. The languages and its content folder is skipped.

	function copy_dir($from, $to, $skip_list = array() ) {
		global $wp_filesystem;

		$dirlist = $wp_filesystem->dirlist($from);

		$from = trailingslashit($from);
		$to = trailingslashit($to);

		$skip_regex = '';
		foreach ( (array)$skip_list as $key => $skip_file )
			$skip_regex .= preg_quote($skip_file, '!') . '|';

		if ( !empty($skip_regex) )
			$skip_regex = '!(' . rtrim($skip_regex, '|') . ')$!i';

		foreach ( (array) $dirlist as $filename => $fileinfo ) {
			if ( !empty($skip_regex) )
				if ( preg_match($skip_regex, $from . $filename) )
					continue;

			if ( 'f' == $fileinfo['type'] ) {
				if ( ! $wp_filesystem->copy($from . $filename, $to . $filename, true, FS_CHMOD_FILE) ) {
					// If copy failed, chmod file to 0644 and try again.
					$wp_filesystem->chmod($to . $filename, 0644);
					if ( ! $wp_filesystem->copy($from . $filename, $to . $filename, true, FS_CHMOD_FILE) )
						return new WP_Error('copy_failed', __('Could not copy file.'), $to . $filename);
				}
			} elseif ( 'd' == $fileinfo['type'] ) {
				if ( !$wp_filesystem->is_dir($to . $filename) ) {
					if ( !$wp_filesystem->mkdir($to . $filename, FS_CHMOD_DIR) )
						return new WP_Error('mkdir_failed', __('Could not create directory.'), $to . $filename);
				}
				$result = copy_dir($from . $filename, $to . $filename, $skip_list);
				if ( is_wp_error($result) )
					return $result;
			}
		}
		return true;
	}

The current dir_copy() function does not skip folders and its content.

Can you please provide a fix for this as the description of dir_copy states it skips files/folders by the skip list.

Or am i doing something wrong? Do i have to provide the complete path to the languages folder? In v3.2.1 we did not have todo this.

Please advice

Change History (12)

#1 @neo2k23
6 weeks ago

This code does not skip folders which is part of the current copy_dir() function.

	foreach ( (array) $dirlist as $filename => $fileinfo ) {
		if ( in_array( $filename, $skip_list, true ) ) {
			continue;
		}

if i change it to this it works again

	$skip_regex = '';
	foreach ( (array)$skip_list as $key => $skip_file )
		$skip_regex .= preg_quote($skip_file, '!') . '|';

	if ( !empty($skip_regex) )
		$skip_regex = '!(' . rtrim($skip_regex, '|') . ')$!i';

	foreach ( (array) $dirlist as $filename => $fileinfo ) {
		if ( !empty($skip_regex) )
			if ( preg_match($skip_regex, $from . $filename) )
				continue;
Last edited 6 weeks ago by neo2k23 (previous) (diff)

#2 @hbhalodia
6 weeks ago

Hi @neo2k23, Thanks for the ticket. I can verify the issue.

The issue is only with the nested subfolders if that contain the name, but within same level it is getting skipped. But we also need to remove for nested subfolders. In earlier version, the regex based matching was used, which captured from nested subfolders as well, hence it was working. While when doing conversion to array based matching, the nested folders scenario was not handled.

AI Usage

  • GH Copilot
  • Claude 4.6 Opus
  • Asked AI to review the issue with current copy_dir function and was used in version 3.2.1. Sharing below the detailed summary.

Root Cause

Background

In WordPress ≤ 3.2.1, copy_dir() used a regex-based skip mechanism. It built a pattern from the $skip_list and matched it against the full path ($from . $filename) using a $ anchor. The entire $skip_list was passed unchanged to every recursive call:

// WP 3.2.1 — old behavior
$skip_regex = '!(' . rtrim($skip_regex, '|') . ')$!i';

// Matched against full path at every depth
if ( preg_match( $skip_regex, $from . $filename ) )
    continue;

// Full skip_list passed to recursive calls
$result = copy_dir( $from . $filename, $to . $filename, $skip_list );

This meant a bare folder name like 'wp-content' in the skip list would match at any nesting depth because the regex anchored to the end of the full path.

What Changed (Post-3.2.1)

The function was refactored to use in_array() for matching and a decomposed $sub_skip_list for recursive calls:

// Current code — only top-level match works
if ( in_array( $filename, $skip_list, true ) ) {
    continue;
}

// Only path-prefixed items are propagated
$sub_skip_list = array();
foreach ( $skip_list as $skip_item ) {
    if ( str_starts_with( $skip_item, $filename . '/' ) ) {
        $sub_skip_list[] = preg_replace( '!^' . preg_quote( $filename, '!' ) . '/!i', '', $skip_item );
    }
}
$result = copy_dir( $from . $filename, $to . $filename, $sub_skip_list );

The $sub_skip_list generation only keeps items prefixed with $filename . '/' and strips the prefix. Bare names (without /) are silently dropped, so they never reach deeper recursion levels.

Example

Given this structure and $skip_list = array( 'skip-me' ):

source/
├── skip-me/               ← Skipped ✅ (top-level match via in_array)
├── folderA/
│   └── skip-me/           ← NOT skipped ❌ (dropped from sub_skip_list)
└── folderB/
    └── folderC/
        └── skip-me/       ← NOT skipped ❌ (dropped from sub_skip_list)

When recursing into folderA, the loop checks str_starts_with( 'skip-me', 'folderA/' )false, so 'skip-me' is excluded from $sub_skip_list. The nested skip-me/ folder is then copied when it shouldn't be.

Fix

Added an elseif branch that propagates bare names (those without /) into the $sub_skip_list:

 foreach ( $skip_list as $skip_item ) {
     if ( str_starts_with( $skip_item, $filename . '/' ) ) {
         $sub_skip_list[] = preg_replace( '!^' . preg_quote( $filename, '!' ) . '/!i', '', $skip_item );
+    } elseif ( ! str_contains( $skip_item, '/' ) ) {
+        $sub_skip_list[] = $skip_item;
     }
 }

Why This Works

  • Path-based items (e.g. 'wp-admin/about.php') continue to be decomposed as before — only 'about.php' is passed when recursing into wp-admin/.
  • Bare names (e.g. 'wp-content', 'skip-me') are now passed through to all subdirectory levels, restoring the pre-3.2.1 behavior where they are matched at every depth.
  • Items containing / that don't match the current directory prefix are correctly excluded (they belong to a different subtree).

Thanks,

This ticket was mentioned in PR #11677 on WordPress/wordpress-develop by @hbhalodia.


6 weeks ago
#3

  • Keywords has-patch added

Trac ticket: https://core.trac.wordpress.org/ticket/65150

  • Add the condition to not skip nested folders when provided in skip_list in copy_dir function.
  • For detailed issue and fix please see the ticket.

## Use of AI Tools

AI assistance: Yes
Tool(s): GitHub Copilot
Model(s): Claude Opus 4.6
Used for: Review copy_dir function and what changed in newer version. Find root cause and fix. Used ot generate the detailed markup to paste in ticket.

#4 @neo2k23
6 weeks ago

Hi @hbhalodia

That is the issue. The deeper subfolders with the name in the skiplist array are not skipped.

themefolder/
├── languages/               ← Skipped ✅ (top-level match via in_array)
├── folderA/
│   └── languages/           ← NOT skipped ❌ (dropped from sub_skip_list)
└── folderB/
    └── folderC/
        └── languages/       ← NOT skipped ❌ (dropped from sub_skip_list)

Thank you for your investigation.

#5 @neo2k23
6 weeks ago

How can this work?

str_contains( $skip_item, '/' )

Shouldn't you check if the filename contains $skip_item?

Last edited 6 weeks ago by neo2k23 (previous) (diff)

#6 @hbhalodia
6 weeks ago

Hi @neo2k23, Thanks for pointing it out.

str_contains( $skip_item, '/' )

It is not the actual place where the copy_dir function checks to skip the file/folder or whether to include it.

The current update which I have shared only determines, whether the item which we need to skip is a bare-name (without '/' eg: 'abcd') or a path-based-item (with '/' eg: 'xyz/abcd.txt', so that we know whether to propogate it downward or not. As you see in next line we have a recursion, that again calls copy_dir with new parameters to check.

The case you are saying for file check all happens here, line 2033 as shown below.

if ( in_array( $filename, $skip_list, true ) ) {
	continue;
}

Also the above line is a strict match comparision, that is,

in_array( 'skip-me', array('skip-me'), true ) → true → skipped ✅
in_array( 'abcd-skip-me', array('skip-me'), true ) → false → NOT skipped ✅
in_array( 'skip-me-abcd', array('skip-me'), true ) → false → NOT skipped ✅

The line added is only for the propogation, to pass the file down to child diretories unchanged.

So a folder, folderB/folderC/abcd-skip-me/ would never be skipped because 'abcd-skip-me' !== 'skip-me'.

Also you can test the PR attached with multiple edge cases and let me know if there is anything is failing. I would be happy to fix that.

Thanks,

#7 follow-up: @neo2k23
6 weeks ago

Hi @hbhalodia

Thank you for explaining this. It is really appreciated. I can confirm that all is working now with the change made to the copy_dir function.

I did various test and all went ok.

Have a great day & thank you!

#8 in reply to: ↑ 7 @hbhalodia
6 weeks ago

Replying to neo2k23:

Hi @hbhalodia

Thank you for explaining this. It is really appreciated. I can confirm that all is working now with the change made to the copy_dir function.

I did various test and all went ok.

Have a great day & thank you!

Thanks @neo2k23

#9 follow-up: @neo2k23
6 weeks ago

@hbhalodia

I am flabbergasted that this bug exists since wp 3.7.0 (october 24 2013) and nobody ever noticed this.By accident I stumbled across this, because the theme activated was using a renamed _theme_copy_dir function in its code to copy files and I wanted to get rid of it by calling the default wp code.

I am glad it will be resolved soon in a WordPress update.

#10 in reply to: ↑ 9 @hbhalodia
5 weeks ago

Replying to neo2k23:

@hbhalodia

I am flabbergasted that this bug exists since wp 3.7.0 (october 24 2013) and nobody ever noticed this.By accident I stumbled across this, because the theme activated was using a renamed _theme_copy_dir function in its code to copy files and I wanted to get rid of it by calling the default wp code.

I am glad it will be resolved soon in a WordPress update.

Yes @neo2k23, That's surprise for me as well.

Let's wait for a core committer/ wider team to validate this indeed an issue or it was an oversight/ known thing they kept it so long.

#11 @audrasjb
3 weeks ago

Removing trunk version as this is not going to be shipped with WP 7.0 but in the next releases.

#12 @audrasjb
3 weeks ago

  • Version trunk deleted
Note: See TracTickets for help on using tickets.