MDL-38350 Lib: Rename directory before removing it, to reduce races

This commit is contained in:
sam marshall
2020-08-20 16:52:28 +01:00
parent a0fc902eb1
commit 9538ba0d67
3 changed files with 110 additions and 0 deletions
+46
View File
@@ -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;
}
+63
View File
@@ -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));
}
}
+1
View File
@@ -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().