diff --git a/lib/moodlelib.php b/lib/moodlelib.php index c4cab7d5dbf..0283ceb904b 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -9789,6 +9789,39 @@ function get_performance_info() { return $info; } +/** + * Renames a file or directory to a unique name within the same directory. + * + * This function is designed to avoid any potential race conditions, and select an unused name. + * + * @param string $filepath Original filepath + * @param string $prefix Prefix to use for the temporary name + * @return string|bool New file path or false if failed + * @since Moodle 3.10 + */ +function rename_to_unused_name(string $filepath, string $prefix = '_temp_') { + $dir = dirname($filepath); + $basename = $dir . '/' . $prefix; + $limit = 0; + while ($limit < 100) { + // Select a new name based on a random number. + $newfilepath = $basename . md5(mt_rand()); + + // Attempt a rename to that new name. + if (@rename($filepath, $newfilepath)) { + return $newfilepath; + } + + // The first time, do some sanity checks, maybe it is failing for a good reason and there + // is no point trying 100 times if so. + if ($limit === 0 && (!file_exists($filepath) || !is_writable($dir))) { + return false; + } + $limit++; + } + return false; +} + /** * Delete directory or only its content * @@ -9801,6 +9834,19 @@ function remove_dir($dir, $contentonly=false) { // Nothing to do. return true; } + + if (!$contentonly) { + // Start by renaming the directory; this will guarantee that other processes don't write to it + // while it is in the process of being deleted. + $tempdir = rename_to_unused_name($dir); + if ($tempdir) { + // If the rename was successful then delete the $tempdir instead. + $dir = $tempdir; + } + // If the rename fails, we will continue through and attempt to delete the directory + // without renaming it since that is likely to at least delete most of the files. + } + if (!$handle = opendir($dir)) { return false; } diff --git a/lib/tests/moodlelib_test.php b/lib/tests/moodlelib_test.php index 3f40d078d4d..cbb7dc2d925 100644 --- a/lib/tests/moodlelib_test.php +++ b/lib/tests/moodlelib_test.php @@ -4713,4 +4713,67 @@ class core_moodlelib_testcase extends advanced_testcase { ], ]; } + + /** + * Tests the rename_to_unused_name function with a file. + */ + public function test_rename_to_unused_name_file() { + global $CFG; + + // Create a new file in dataroot. + $file = $CFG->dataroot . '/argh.txt'; + file_put_contents($file, 'Frogs'); + + // Rename it. + $newname = rename_to_unused_name($file); + + // Check new name has expected format. + $this->assertRegExp('~/_temp_[a-f0-9]+$~', $newname); + + // Check it's still in the same folder. + $this->assertEquals($CFG->dataroot, dirname($newname)); + + // Check file can be loaded. + $this->assertEquals('Frogs', file_get_contents($newname)); + + // OK, delete the file. + unlink($newname); + } + + /** + * Tests the rename_to_unused_name function with a directory. + */ + public function test_rename_to_unused_name_dir() { + global $CFG; + + // Create a new directory in dataroot. + $file = $CFG->dataroot . '/arghdir'; + mkdir($file); + + // Rename it. + $newname = rename_to_unused_name($file); + + // Check new name has expected format. + $this->assertRegExp('~/_temp_[a-f0-9]+$~', $newname); + + // Check it's still in the same folder. + $this->assertEquals($CFG->dataroot, dirname($newname)); + + // Check it's still a directory + $this->assertTrue(is_dir($newname)); + + // OK, delete the directory. + rmdir($newname); + } + + /** + * Tests the rename_to_unused_name function with error cases. + */ + public function test_rename_to_unused_name_failure() { + global $CFG; + + // Rename a file that doesn't exist. + $file = $CFG->dataroot . '/argh.txt'; + $this->assertFalse(rename_to_unused_name($file)); + } } diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 60a675eba42..665a52d48ae 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -36,6 +36,7 @@ information provided here is intended especially for developers. a callback function instead of an array of options. * Admin setting admin_setting_configselect now supports validating the selection by supplying a callback function. +* New library function rename_to_unused_name() to rename a file within its current location. === 3.9 === * Following function has been deprecated, please use \core\task\manager::run_from_cli().