PocketMine-MP/src/utils/Filesystem.php
2023-01-06 01:59:04 +00:00

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);
}
}
}