Avoid file_put_contents() when overwriting files

this fixes many cases of corruption during disk-full situations - file_put_contents() would write an empty file, destroying the original data.
fixes #3152
This commit is contained in:
Dylan K. Taylor 2021-12-05 00:26:48 +00:00
parent 8e8cee45b8
commit 8e37f86480
No known key found for this signature in database
GPG Key ID: 8927471A91CAFD3D
5 changed files with 63 additions and 6 deletions

View File

@ -538,8 +538,8 @@ class Server{
Timings::$syncPlayerDataSave->time(function() use ($name, $ev) : void{
$nbt = new BigEndianNbtSerializer();
try{
file_put_contents($this->getPlayerDataPath($name), zlib_encode($nbt->write(new TreeRoot($ev->getSaveData())), ZLIB_ENCODING_GZIP));
}catch(\ErrorException $e){
Filesystem::safeFilePutContents($this->getPlayerDataPath($name), zlib_encode($nbt->write(new TreeRoot($ev->getSaveData())), ZLIB_ENCODING_GZIP));
}catch(\RuntimeException | \ErrorException $e){
$this->logger->critical($this->getLanguage()->translate(KnownTranslationFactory::pocketmine_data_saveError($name, $e->getMessage())));
$this->logger->logException($e);
}

View File

@ -33,7 +33,6 @@ use function date;
use function explode;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function implode;
use function is_array;
use function is_bool;
@ -228,7 +227,7 @@ class Config{
throw new AssumptionFailedError("Config type is unknown, has not been set or not detected");
}
file_put_contents($this->file, $content);
Filesystem::safeFilePutContents($this->file, $content);
$this->changed = false;
}

View File

@ -26,9 +26,11 @@ namespace pocketmine\utils;
use Webmozart\PathUtil\Path;
use function copy;
use function dirname;
use function disk_free_space;
use function fclose;
use function fflush;
use function file_exists;
use function file_put_contents;
use function flock;
use function fopen;
use function ftruncate;
@ -40,6 +42,7 @@ use function ltrim;
use function mkdir;
use function preg_match;
use function realpath;
use function rename;
use function rmdir;
use function rtrim;
use function scandir;
@ -221,4 +224,53 @@ final class Filesystem{
@unlink($lockFilePath);
}
}
/**
* Wrapper around file_put_contents() which writes to a temporary file before overwriting the original. If the disk
* is full, writing to the temporary file will fail before the original file is modified, leaving it untouched.
*
* This is necessary because file_put_contents() destroys the data currently in the file if it fails to write the
* new contents.
*
* @param resource|null $context Context to pass to file_put_contents
*/
public static function safeFilePutContents(string $fileName, string $contents, int $flags = 0, $context = null) : void{
$directory = dirname($fileName);
if(!is_dir($directory)){
throw new \RuntimeException("Target directory path does not exist or is not a directory");
}
if(is_dir($fileName)){
throw new \RuntimeException("Target file path already exists and is not a file");
}
$counter = 0;
do{
//we don't care about overwriting any preexisting tmpfile but we can't write if a directory is already here
$temporaryFileName = $fileName . ".$counter.tmp";
$counter++;
}while(is_dir($temporaryFileName));
$writeTemporaryFileResult = $context !== null ?
file_put_contents($temporaryFileName, $contents, $flags, $context) :
file_put_contents($temporaryFileName, $contents, $flags);
if($writeTemporaryFileResult !== strlen($contents)){
$context !== null ?
@unlink($temporaryFileName, $context) :
@unlink($temporaryFileName);
$diskSpace = disk_free_space($directory);
if($diskSpace !== false && $diskSpace < strlen($contents)){
throw new \RuntimeException("Failed to write to temporary file $temporaryFileName (out of free disk space)");
}
throw new \RuntimeException("Failed to write to temporary file $temporaryFileName (possibly out of free disk space)");
}
$renameTemporaryFileResult = $context !== null ?
rename($temporaryFileName, $fileName, $context) :
rename($temporaryFileName, $fileName);
if(!$renameTemporaryFileResult){
throw new \RuntimeException("Failed to move temporary file contents into target file");
}
}
}

View File

@ -31,6 +31,7 @@ use pocketmine\nbt\tag\StringTag;
use pocketmine\nbt\TreeRoot;
use pocketmine\network\mcpe\protocol\ProtocolInfo;
use pocketmine\utils\Binary;
use pocketmine\utils\Filesystem;
use pocketmine\utils\Limits;
use pocketmine\world\format\io\exception\CorruptedWorldException;
use pocketmine\world\format\io\exception\UnsupportedWorldFormatException;
@ -165,7 +166,7 @@ class BedrockWorldData extends BaseNbtWorldData{
$nbt = new LittleEndianNbtSerializer();
$buffer = $nbt->write(new TreeRoot($this->compoundTag));
file_put_contents($this->dataPath, Binary::writeLInt(self::CURRENT_STORAGE_VERSION) . Binary::writeLInt(strlen($buffer)) . $buffer);
Filesystem::safeFilePutContents($this->dataPath, Binary::writeLInt(self::CURRENT_STORAGE_VERSION) . Binary::writeLInt(strlen($buffer)) . $buffer);
}
public function getDifficulty() : int{

View File

@ -29,6 +29,8 @@ use pocketmine\nbt\tag\CompoundTag;
use pocketmine\nbt\tag\FloatTag;
use pocketmine\nbt\tag\StringTag;
use pocketmine\nbt\TreeRoot;
use pocketmine\utils\AssumptionFailedError;
use pocketmine\utils\Filesystem;
use pocketmine\world\format\io\exception\CorruptedWorldException;
use pocketmine\world\generator\GeneratorManager;
use pocketmine\world\World;
@ -111,7 +113,10 @@ class JavaWorldData extends BaseNbtWorldData{
public function save() : void{
$nbt = new BigEndianNbtSerializer();
$buffer = zlib_encode($nbt->write(new TreeRoot(CompoundTag::create()->setTag("Data", $this->compoundTag))), ZLIB_ENCODING_GZIP);
file_put_contents($this->dataPath, $buffer);
if($buffer === false){
throw new AssumptionFailedError("zlib_encode() failed unexpectedly");
}
Filesystem::safeFilePutContents($this->dataPath, $buffer);
}
public function getDifficulty() : int{