diff --git a/src/Server.php b/src/Server.php index 75971a108..e46525b4c 100644 --- a/src/Server.php +++ b/src/Server.php @@ -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); } diff --git a/src/utils/Config.php b/src/utils/Config.php index 6397bd13d..c3a791095 100644 --- a/src/utils/Config.php +++ b/src/utils/Config.php @@ -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; } diff --git a/src/utils/Filesystem.php b/src/utils/Filesystem.php index 2d107af6e..fd3eca26b 100644 --- a/src/utils/Filesystem.php +++ b/src/utils/Filesystem.php @@ -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"); + } + } } diff --git a/src/world/format/io/data/BedrockWorldData.php b/src/world/format/io/data/BedrockWorldData.php index 50a4184fe..32fde0439 100644 --- a/src/world/format/io/data/BedrockWorldData.php +++ b/src/world/format/io/data/BedrockWorldData.php @@ -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{ diff --git a/src/world/format/io/data/JavaWorldData.php b/src/world/format/io/data/JavaWorldData.php index c2f488876..cacecae29 100644 --- a/src/world/format/io/data/JavaWorldData.php +++ b/src/world/format/io/data/JavaWorldData.php @@ -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{