mirror of
https://github.com/pmmp/PocketMine-MP.git
synced 2025-05-29 00:26:56 +00:00
313 lines
11 KiB
PHP
313 lines
11 KiB
PHP
<?php
|
|
|
|
/*
|
|
*
|
|
* ____ _ _ __ __ _ __ __ ____
|
|
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
|
|
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
|
|
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
|
|
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Lesser General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* @author PocketMine Team
|
|
* @link http://www.pocketmine.net/
|
|
*
|
|
*
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace pocketmine\utils;
|
|
|
|
use pocketmine\errorhandler\ErrorToExceptionHandler;
|
|
use Symfony\Component\Filesystem\Path;
|
|
use function copy;
|
|
use function dirname;
|
|
use function fclose;
|
|
use function fflush;
|
|
use function file_exists;
|
|
use function file_get_contents;
|
|
use function file_put_contents;
|
|
use function flock;
|
|
use function fopen;
|
|
use function ftruncate;
|
|
use function fwrite;
|
|
use function getmypid;
|
|
use function is_dir;
|
|
use function is_file;
|
|
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;
|
|
use function str_replace;
|
|
use function str_starts_with;
|
|
use function stream_get_contents;
|
|
use function strlen;
|
|
use function uksort;
|
|
use function unlink;
|
|
use const DIRECTORY_SEPARATOR;
|
|
use const LOCK_EX;
|
|
use const LOCK_NB;
|
|
use const LOCK_SH;
|
|
use const LOCK_UN;
|
|
use const SCANDIR_SORT_NONE;
|
|
|
|
final class Filesystem{
|
|
/** @var resource[] */
|
|
private static array $lockFileHandles = [];
|
|
/**
|
|
* @var string[]
|
|
* @phpstan-var array<string, string>
|
|
*/
|
|
private static array $cleanedPaths = [
|
|
\pocketmine\PATH => self::CLEAN_PATH_SRC_PREFIX
|
|
];
|
|
|
|
public const CLEAN_PATH_SRC_PREFIX = "pmsrc";
|
|
public const CLEAN_PATH_PLUGINS_PREFIX = "plugins";
|
|
|
|
private function __construct(){
|
|
//NOOP
|
|
}
|
|
|
|
public static function recursiveUnlink(string $dir) : void{
|
|
if(is_dir($dir)){
|
|
$objects = Utils::assumeNotFalse(scandir($dir, SCANDIR_SORT_NONE), "scandir() shouldn't return false when is_dir() returns true");
|
|
foreach($objects as $object){
|
|
if($object !== "." && $object !== ".."){
|
|
$fullObject = Path::join($dir, $object);
|
|
if(is_dir($fullObject)){
|
|
self::recursiveUnlink($fullObject);
|
|
}else{
|
|
unlink($fullObject);
|
|
}
|
|
}
|
|
}
|
|
rmdir($dir);
|
|
}elseif(is_file($dir)){
|
|
unlink($dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively copies a directory to a new location. The parent directories for the destination must exist.
|
|
*/
|
|
public static function recursiveCopy(string $origin, string $destination) : void{
|
|
if(!is_dir($origin)){
|
|
throw new \RuntimeException("$origin does not exist, or is not a directory");
|
|
}
|
|
if(!is_dir($destination)){
|
|
if(file_exists($destination)){
|
|
throw new \RuntimeException("$destination already exists, and is not a directory");
|
|
}
|
|
if(!is_dir(dirname($destination))){
|
|
//if the parent dir doesn't exist, the user most likely made a mistake
|
|
throw new \RuntimeException("The parent directory of $destination does not exist, or is not a directory");
|
|
}
|
|
try{
|
|
ErrorToExceptionHandler::trap(fn() => mkdir($destination));
|
|
}catch(\ErrorException $e){
|
|
if(!is_dir($destination)){
|
|
throw new \RuntimeException("Failed to create output directory $destination: " . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
self::recursiveCopyInternal($origin, $destination);
|
|
}
|
|
|
|
private static function recursiveCopyInternal(string $origin, string $destination) : void{
|
|
if(is_dir($origin)){
|
|
if(!is_dir($destination)){
|
|
if(file_exists($destination)){
|
|
throw new \RuntimeException("Path $destination does not exist, or is not a directory");
|
|
}
|
|
mkdir($destination); //TODO: access permissions?
|
|
}
|
|
$objects = Utils::assumeNotFalse(scandir($origin, SCANDIR_SORT_NONE));
|
|
foreach($objects as $object){
|
|
if($object === "." || $object === ".."){
|
|
continue;
|
|
}
|
|
self::recursiveCopyInternal(Path::join($origin, $object), Path::join($destination, $object));
|
|
}
|
|
}else{
|
|
$dirName = dirname($destination);
|
|
if(!is_dir($dirName)){ //the destination folder should already exist
|
|
throw new AssumptionFailedError("The destination folder should have been created in the parent call");
|
|
}
|
|
copy($origin, $destination);
|
|
}
|
|
}
|
|
|
|
public static function addCleanedPath(string $path, string $replacement) : void{
|
|
self::$cleanedPaths[$path] = $replacement;
|
|
uksort(self::$cleanedPaths, function(string $str1, string $str2) : int{
|
|
return strlen($str2) <=> strlen($str1); //longest first
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
* @phpstan-return array<string, string>
|
|
*/
|
|
public static function getCleanedPaths() : array{ return self::$cleanedPaths; }
|
|
|
|
public static function cleanPath(string $path) : string{
|
|
$result = str_replace([DIRECTORY_SEPARATOR, ".php", "phar://"], ["/", "", ""], $path);
|
|
|
|
//remove relative paths
|
|
//this should probably never have integer keys, but it's safer than making PHPStan ignore it
|
|
foreach(Utils::stringifyKeys(self::$cleanedPaths) as $cleanPath => $replacement){
|
|
$cleanPath = rtrim(str_replace([DIRECTORY_SEPARATOR, "phar://"], ["/", ""], $cleanPath), "/");
|
|
if(str_starts_with($result, $cleanPath)){
|
|
$result = ltrim(str_replace($cleanPath, $replacement, $result), "/");
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Attempts to get a lock on the specified file, creating it if it does not exist. This is typically used for IPC to
|
|
* inform other processes that some file or folder is already in use, to avoid data corruption.
|
|
* If this function succeeds in gaining a lock on the file, it writes the current PID to the file.
|
|
*
|
|
* @return int|null process ID of the process currently holding the lock failure, null on success.
|
|
* @throws \InvalidArgumentException if the lock file path is invalid (e.g. parent directory doesn't exist, permission denied)
|
|
*/
|
|
public static function createLockFile(string $lockFilePath) : ?int{
|
|
try{
|
|
$resource = ErrorToExceptionHandler::trapAndRemoveFalse(fn() => fopen($lockFilePath, "a+b"));
|
|
}catch(\ErrorException $e){
|
|
throw new \InvalidArgumentException("Failed to open lock file: " . $e->getMessage(), 0, $e);
|
|
}
|
|
if(!flock($resource, LOCK_EX | LOCK_NB)){
|
|
//wait for a shared lock to avoid race conditions if two servers started at the same time - this makes sure the
|
|
//other server wrote its PID and released exclusive lock before we get our lock
|
|
flock($resource, LOCK_SH);
|
|
$pid = Utils::assumeNotFalse(stream_get_contents($resource), "This is a known valid file resource, at worst we should receive an empty string");
|
|
if(preg_match('/^\d+$/', $pid) === 1){
|
|
return (int) $pid;
|
|
}
|
|
return -1;
|
|
}
|
|
ftruncate($resource, 0);
|
|
fwrite($resource, (string) getmypid());
|
|
fflush($resource);
|
|
flock($resource, LOCK_SH); //prevent acquiring an exclusive lock from another process, but allow reading
|
|
self::$lockFileHandles[realpath($lockFilePath)] = $resource; //keep the resource alive to preserve the lock
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Releases a file lock previously acquired by createLockFile() and deletes the lock file.
|
|
*
|
|
* @throws \InvalidArgumentException if the lock file path is invalid (e.g. parent directory doesn't exist, permission denied)
|
|
*/
|
|
public static function releaseLockFile(string $lockFilePath) : void{
|
|
$lockFilePath = realpath($lockFilePath);
|
|
if($lockFilePath === false){
|
|
throw new \InvalidArgumentException("Invalid lock file path");
|
|
}
|
|
if(isset(self::$lockFileHandles[$lockFilePath])){
|
|
flock(self::$lockFileHandles[$lockFilePath], LOCK_UN);
|
|
fclose(self::$lockFileHandles[$lockFilePath]);
|
|
unset(self::$lockFileHandles[$lockFilePath]);
|
|
@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
|
|
*
|
|
* @throws \RuntimeException if the operation failed for any reason
|
|
*/
|
|
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));
|
|
|
|
try{
|
|
ErrorToExceptionHandler::trap(fn() => $context !== null ?
|
|
file_put_contents($temporaryFileName, $contents, $flags, $context) :
|
|
file_put_contents($temporaryFileName, $contents, $flags)
|
|
);
|
|
}catch(\ErrorException $filePutContentsException){
|
|
$context !== null ?
|
|
@unlink($temporaryFileName, $context) :
|
|
@unlink($temporaryFileName);
|
|
throw new \RuntimeException("Failed to write to temporary file $temporaryFileName: " . $filePutContentsException->getMessage(), 0, $filePutContentsException);
|
|
}
|
|
|
|
$renameTemporaryFileResult = $context !== null ?
|
|
@rename($temporaryFileName, $fileName, $context) :
|
|
@rename($temporaryFileName, $fileName);
|
|
if(!$renameTemporaryFileResult){
|
|
/*
|
|
* The following code works around a bug in Windows where rename() will periodically decide to give us a
|
|
* spurious "Access is denied (code: 5)" error. As far as I could determine, the fault comes from Windows
|
|
* itself, but since I couldn't reliably reproduce the issue it's very hard to debug.
|
|
*
|
|
* The following code can be used to test. Usually it will fail anywhere before 100,000 iterations.
|
|
*
|
|
* for($i = 0; $i < 10_000_000; ++$i){
|
|
* file_put_contents('ops.txt.0.tmp', 'some data ' . $i, 0);
|
|
* if(!rename('ops.txt.0.tmp', 'ops.txt')){
|
|
* throw new \Error("something weird happened");
|
|
* }
|
|
* }
|
|
*/
|
|
try{
|
|
ErrorToExceptionHandler::trap(fn() => $context !== null ?
|
|
copy($temporaryFileName, $fileName, $context) :
|
|
copy($temporaryFileName, $fileName)
|
|
);
|
|
}catch(\ErrorException $copyException){
|
|
throw new \RuntimeException("Failed to move temporary file contents into target file: " . $copyException->getMessage(), 0, $copyException);
|
|
}
|
|
@unlink($temporaryFileName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrapper around file_get_contents() which throws an exception instead of generating E_* errors.
|
|
*
|
|
* @phpstan-param resource|null $context
|
|
* @phpstan-param 0|positive-int $offset
|
|
* @phpstan-param 0|positive-int|null $length
|
|
*
|
|
* @throws \RuntimeException
|
|
*/
|
|
public static function fileGetContents(string $fileName, bool $useIncludePath = false, $context = null, int $offset = 0, ?int $length = null) : string{
|
|
try{
|
|
return ErrorToExceptionHandler::trapAndRemoveFalse(fn() => file_get_contents($fileName, $useIncludePath, $context, $offset, $length));
|
|
}catch(\ErrorException $e){
|
|
throw new \RuntimeException("Failed to read file $fileName: " . $e->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
}
|