<?php
declare(strict_types=1);
* @package Grav\Framework\Filesystem
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Framework\Filesystem;
use Grav\Framework\Filesystem\Interfaces\FilesystemInterface;
use RuntimeException;
use function count;
use function dirname;
use function is_array;
use function pathinfo;
* Class Filesystem
* @package Grav\Framework\Filesystem
*/
class Filesystem implements FilesystemInterface
{
private $normalize;
protected static $default;
protected static $unsafe;
protected static $safe;
* @param bool|null $normalize See $this->setNormalization()
* @return Filesystem
*/
public static function getInstance(bool $normalize = null): Filesystem
{
if ($normalize === true) {
$instance = &static::$safe;
} elseif ($normalize === false) {
$instance = &static::$unsafe;
} else {
$instance = &static::$default;
}
if (null === $instance) {
$instance = new static($normalize);
}
return $instance;
}
* Always use Filesystem::getInstance() instead.
*
* @param bool|null $normalize
* @internal
*/
protected function __construct(bool $normalize = null)
{
$this->normalize = $normalize;
}
* Set path normalization.
*
* Default option enables normalization for the streams only, but you can force the normalization to be either
* on or off for every path. Disabling path normalization speeds up the calls, but may cause issues if paths were
* not normalized.
*
* @param bool|null $normalize
* @return Filesystem
*/
public function setNormalization(bool $normalize = null): self
{
return static::getInstance($normalize);
}
* @return bool|null
*/
public function getNormalization(): ?bool
{
return $this->normalize;
}
* Force all paths to be normalized.
*
* @return self
*/
public function unsafe(): self
{
return static::getInstance(true);
}
* Force all paths not to be normalized (speeds up the calls if given paths are known to be normalized).
*
* @return self
*/
public function safe(): self
{
return static::getInstance(false);
}
* {@inheritdoc}
* @see FilesystemInterface::parent()
*/
public function parent(string $path, int $levels = 1): string
{
[$scheme, $path] = $this->getSchemeAndHierarchy($path);
if ($this->normalize !== false) {
$path = $this->normalizePathPart($path);
}
if ($path === '' || $path === '.') {
return '';
}
[$scheme, $parent] = $this->dirnameInternal($scheme, $path, $levels);
return $parent !== $path ? $this->toString($scheme, $parent) : '';
}
* {@inheritdoc}
* @see FilesystemInterface::normalize()
*/
public function normalize(string $path): string
{
[$scheme, $path] = $this->getSchemeAndHierarchy($path);
$path = $this->normalizePathPart($path);
return $this->toString($scheme, $path);
}
* {@inheritdoc}
* @see FilesystemInterface::basename()
*/
public function basename(string $path, ?string $suffix = null): string
{
$path = str_replace(['%2F', '%5C'], '/', rawurlencode($path));
return rawurldecode($suffix ? basename($path, $suffix) : basename($path));
}
* {@inheritdoc}
* @see FilesystemInterface::dirname()
*/
public function dirname(string $path, int $levels = 1): string
{
[$scheme, $path] = $this->getSchemeAndHierarchy($path);
if ($this->normalize || ($scheme && null === $this->normalize)) {
$path = $this->normalizePathPart($path);
}
[$scheme, $path] = $this->dirnameInternal($scheme, $path, $levels);
return $this->toString($scheme, $path);
}
* Gets full path with trailing slash.
*
* @param string $path
* @param int $levels
* @return string
* @phpstan-param positive-int $levels
*/
public function pathname(string $path, int $levels = 1): string
{
$path = $this->dirname($path, $levels);
return $path !== '.' ? $path . '/' : '';
}
* {@inheritdoc}
* @see FilesystemInterface::pathinfo()
*/
public function pathinfo(string $path, ?int $options = null)
{
[$scheme, $path] = $this->getSchemeAndHierarchy($path);
if ($this->normalize || ($scheme && null === $this->normalize)) {
$path = $this->normalizePathPart($path);
}
return $this->pathinfoInternal($scheme, $path, $options);
}
* @param string|null $scheme
* @param string $path
* @param int $levels
* @return array
* @phpstan-param positive-int $levels
*/
protected function dirnameInternal(?string $scheme, string $path, int $levels = 1): array
{
$path = dirname($path, $levels);
if (null !== $scheme && $path === '.') {
return [$scheme, ''];
}
if (DIRECTORY_SEPARATOR !== '/') {
$path = str_replace('\\', '/', $path);
}
return [$scheme, $path];
}
* @param string|null $scheme
* @param string $path
* @param int|null $options
* @return array|string
*/
protected function pathinfoInternal(?string $scheme, string $path, ?int $options = null)
{
$path = str_replace(['%2F', '%5C'], ['/', '\\'], rawurlencode($path));
if (null === $options) {
$info = pathinfo($path);
} else {
$info = pathinfo($path, $options);
}
if (!is_array($info)) {
return rawurldecode($info);
}
$info = array_map('rawurldecode', $info);
if (null !== $scheme) {
$info['scheme'] = $scheme;
$dirname = $info['dirname'] ?? '.';
if ('' !== $dirname && '.' !== $dirname) {
if (DIRECTORY_SEPARATOR !== '/') {
$dirname = str_replace(DIRECTORY_SEPARATOR, '/', $dirname);
}
$info['dirname'] = $scheme . '://' . $dirname;
} else {
$info = ['dirname' => $scheme . '://'] + $info;
}
}
return $info;
}
* Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> array(file, tmp)).
*
* @param string $filename
* @return array
*/
protected function getSchemeAndHierarchy(string $filename): array
{
$components = explode('://', $filename, 2);
return 2 === count($components) ? $components : [null, $components[0]];
}
* @param string|null $scheme
* @param string $path
* @return string
*/
protected function toString(?string $scheme, string $path): string
{
if ($scheme) {
return $scheme . '://' . $path;
}
return $path;
}
* @param string $path
* @return string
* @throws RuntimeException
*/
protected function normalizePathPart(string $path): string
{
if ($path === '' || $path === '.') {
return '';
}
if ($path === '/') {
return '/';
}
if ($path[-1] !== '/' && !preg_match('`(\\\\|\./|//|\.\.)`', $path)) {
return $path;
}
$path = strtr($path, ['\\' => '/']);
$parts = explode('/', $path);
$root = '';
if ($parts[0] === '') {
$root = '/';
array_shift($parts);
}
$list = [];
foreach ($parts as $i => $part) {
if ($part === '' || $part === '.') {
continue;
}
if ($part === '..') {
$test = array_pop($list);
if ($test === null) {
throw new RuntimeException("Bad path {$path}");
}
} else {
$list[] = $part;
}
}
return $root . implode('/', $list);
}
}