Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions src/Filesystem/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
defined('ABSPATH') || exit;

use Exception;
use Framework\Container;
use InvalidArgumentException;
use SplFileInfo;

Expand Down Expand Up @@ -61,29 +62,17 @@ public function move(string $directory, ?string $name = null)
{
$target = $this->get_target_file($directory, $name);

// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Captures a PHP warning from rename()/move_uploaded_file() into a catchable value; restored in the finally block immediately after.
set_error_handler(static function ($type, $msg) use (&$error) {
$error = $msg;
});
$filesystem = Container::get_instance()->make(Filesystem::class);

try {
$renamed = rename($this->getPathname(), $target);
} finally {
restore_error_handler();
}
$renamed = $filesystem->move($this->getPathname(), $target);

throw_unless(
$renamed,
sprintf(
'Could not move the file "%s" to "%s" (%s).',
$this->getPathname(),
$target,
wp_strip_all_tags($error ?? '')
),
sprintf('Could not move the file "%s" to "%s".', $this->getPathname(), $target),
Exception::class
);

@chmod($target, 0666 & ~umask());
$filesystem->chmod($target, 0666 & ~umask());

return $target;
}
Expand Down Expand Up @@ -120,15 +109,15 @@ public function get_content()
*/
protected function get_target_file(string $directory, ?string $name = null)
{
if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) {
if (!is_dir($directory) && !wp_mkdir_p($directory) && !is_dir($directory)) {
throw_if(
is_file($directory),
sprintf('Unable to create the "%s" directory. A similar named file exists.', $directory),
Exception::class
);

throw_anyway(sprintf('Unable to create the "%s" directory.', $directory));
} elseif (!is_writable($directory)) {
} elseif (!wp_is_writable($directory)) {
throw_anyway(sprintf('Unable to write in the "%s" directory.', $directory));
}

Expand Down
20 changes: 4 additions & 16 deletions src/Filesystem/UploadedFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,29 +262,17 @@ public function move(string $directory, ?string $name = null)
if ($this->is_valid()) {
$target = $this->get_target_file($directory, $name);

// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Captures a PHP warning from rename()/move_uploaded_file() into a catchable value; restored in the finally block immediately after.
set_error_handler(static function ($type, $msg) use (&$error) {
$error = $msg;
});
$filesystem = Container::get_instance()->make(Filesystem::class);

try {
$moved = move_uploaded_file($this->getPathname(), $target);
} finally {
restore_error_handler();
}
$moved = $filesystem->move($this->getPathname(), $target);

throw_unless(
$moved,
message(
'upload.move_failed',
$this->getPathname(),
$target,
wp_strip_all_tags($error ?? '')
),
message('upload.move_failed', $this->getPathname(), $target),
Exception::class
);

@chmod($target, 0666 & ~umask());
$filesystem->chmod($target, 0666 & ~umask());

return $target;
}
Expand Down
4 changes: 3 additions & 1 deletion src/Http/Superglobals.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ protected static function read(array $superglobal, ?string $key, $default, $type

// A single-key read expects a scalar; an unexpected array (e.g. a crafted
// "name[]=x" request) is treated as absent rather than stringified by Sanitizer.
if (!is_scalar($value)) {
// The array sanitization type is the deliberate exception: an array value is
// exactly what it expects, and Sanitizer::apply_rule() already handles it.
if (!is_scalar($value) && $type !== Sanitizer::ARRAY) {
return $default;
}

Expand Down
1 change: 1 addition & 0 deletions src/Managers/EventManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ protected function resolve($listener, $event)
{
throw_unless(is_subclass_of($listener, Listener::class), sprintf(
'The listener [%s] must be a subclass of [%s]',
$listener,
Listener::class
), InvalidArgumentException::class);

Expand Down
2 changes: 1 addition & 1 deletion src/Supports/MessagesBag.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ protected function defaults()
],
'upload' => [
'directory_unavailable' => 'Upload directory is not available.',
'move_failed' => 'Could not move the file "%s" to "%s" (%s).',
'move_failed' => 'Could not move the file "%s" to "%s".',
'ini_size_exceeded' => 'The file "%s" exceeds your upload_max_filesize ini directive (limit is %d KiB).',
'form_size_exceeded' => 'The file "%s" exceeds the upload limit defined in your form.',
'partial' => 'The file "%s" was only partially uploaded.',
Expand Down
9 changes: 9 additions & 0 deletions tests/Support/Cache/TestFilesystem.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ public function move($path, $target)
return rename($path, $target);
}

public function chmod($path, $mode = null)
{
if ($mode) {
return chmod($path, $mode);
}

return substr(sprintf('%o', fileperms($path)), -4);
}

public function delete($paths, bool $recursive = true)
{
$paths = is_array($paths) ? $paths : [$paths];
Expand Down
19 changes: 19 additions & 0 deletions tests/Support/Filesystem/UploadedFileStub.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Framework\Filesystem;

/**
* Shadows the real `is_uploaded_file()` for `Framework\Filesystem\UploadedFile::is_valid()`.
*
* PHP's own `is_uploaded_file()` only ever returns true for a file the SAPI actually tracked as
* part of an HTTP upload, which never happens under the CLI test runner. Tests opt a path into
* "genuinely uploaded" by adding it to `$GLOBALS['framework_test_uploaded_files']`; anything else
* falls through to the real function (i.e. stays false), so the safety check is not weakened.
*/
if (!function_exists(__NAMESPACE__ . '\\is_uploaded_file')) {
function is_uploaded_file($filename)
{
return in_array($filename, $GLOBALS['framework_test_uploaded_files'] ?? [], true)
|| \is_uploaded_file($filename);
}
}
36 changes: 36 additions & 0 deletions tests/Support/StubsWordPressFunctions.php
Original file line number Diff line number Diff line change
Expand Up @@ -822,3 +822,39 @@ function wp_generate_password($length = 12, $special_chars = true, $extra_specia
return $password;
}
}

if (!function_exists('wp_mkdir_p')) {
function wp_mkdir_p($target)
{
return is_dir($target) || @mkdir($target, 0777, true);
}
}

if (!function_exists('wp_is_writable')) {
function wp_is_writable($path)
{
if (in_array($path, $GLOBALS['framework_test_unwritable_paths'] ?? [], true)) {
return false;
}

return is_writable($path);
}
}

if (!function_exists('current_user_can')) {
function current_user_can($capability)
{
return $GLOBALS['framework_test_user_can'][$capability] ?? true;
}
}

if (!function_exists('sanitize_file_name')) {
function sanitize_file_name($filename)
{
$special_chars = ['?', '[', ']', '/', '\\', '=', '<', '>', ':', ';', ',', "'", '"', '&', '$', '#', '*', '(', ')', '|', '~', '`', '!', '{', '}', '%', '+'];
$filename = str_replace($special_chars, '', $filename);
$filename = preg_replace('/\s+/', '-', trim($filename));

return trim($filename, '.-_');
}
}
148 changes: 148 additions & 0 deletions tests/Unit/Filesystem/FileTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

namespace Framework\Tests\Unit\Filesystem;

use Exception;
use Framework\Container;
use Framework\Filesystem\File;
use Framework\Filesystem\Filesystem;
use Framework\Tests\Support\Cache\TestFilesystem;
use Framework\Tests\Unit\TestCase;

class FileTest extends TestCase
{
protected string $base_dir;

protected TestFilesystem $files;

protected function setUp(): void
{
parent::setUp();

$this->base_dir = sys_get_temp_dir() . '/framework-file-' . uniqid();
mkdir($this->base_dir, 0777, true);

$this->files = new TestFilesystem();
$this->bind_filesystem($this->files);
}

protected function tearDown(): void
{
$this->files->delete($this->base_dir, true);
unset($GLOBALS['framework_test_unwritable_paths']);

parent::tearDown();
}

protected function bind_filesystem(Filesystem $filesystem): void
{
$container = new Container();
$container->instance('app', $container);
$container->instance(Filesystem::class, $filesystem);

$this->set_container_instance($container);
}

protected function make_source_file(string $name = 'source.txt', string $contents = 'hello'): string
{
$path = $this->base_dir . '/' . $name;
file_put_contents($path, $contents);

return $path;
}

public function test_move_creates_missing_target_directory(): void
{
$source = $this->make_source_file();
$target_dir = $this->base_dir . '/uploads';

$result = (new File($source))->move($target_dir);

$this->assertDirectoryExists($target_dir);
$this->assertFileExists($target_dir . '/source.txt');
$this->assertSame($target_dir . '/source.txt', $result->getPathname());
}

public function test_move_into_existing_directory_succeeds(): void
{
$source = $this->make_source_file();
$target_dir = $this->base_dir . '/uploads';
mkdir($target_dir, 0777, true);

(new File($source))->move($target_dir);

$this->assertFileExists($target_dir . '/source.txt');
}

public function test_move_with_custom_name_uses_given_name(): void
{
$source = $this->make_source_file();
$target_dir = $this->base_dir . '/uploads';

$result = (new File($source))->move($target_dir, 'renamed.txt');

$this->assertSame('renamed.txt', basename($result->getPathname()));
$this->assertFileExists($target_dir . '/renamed.txt');
}

public function test_move_sets_normalized_permissions(): void
{
$source = $this->make_source_file();
$target_dir = $this->base_dir . '/uploads';

$result = (new File($source))->move($target_dir);

$expected = 0666 & ~umask();
$actual = fileperms($result->getPathname()) & 0777;

$this->assertSame($expected, $actual);
}

public function test_move_throws_when_target_path_is_blocked_by_a_file(): void
{
$source = $this->make_source_file();
$blocking_path = $this->base_dir . '/blocked';
file_put_contents($blocking_path, 'i am a file, not a directory');

$this->expectException(Exception::class);
$this->expectExceptionMessageMatches('/similar named file exists/');

(new File($source))->move($blocking_path);
}

public function test_move_throws_when_target_directory_is_not_writable(): void
{
$source = $this->make_source_file();
$target_dir = $this->base_dir . '/readonly';
mkdir($target_dir, 0777, true);

$GLOBALS['framework_test_unwritable_paths'] = [$target_dir];

$this->expectException(Exception::class);
$this->expectExceptionMessageMatches('/Unable to write in the/');

(new File($source))->move($target_dir);
}

public function test_move_throws_when_underlying_move_fails(): void
{
$source = $this->make_source_file();
$target_dir = $this->base_dir . '/uploads';

$this->bind_filesystem(new class extends TestFilesystem {
public function move($path, $target)
{
return false;
}
});

$this->expectException(Exception::class);
$this->expectExceptionMessage(sprintf(
'Could not move the file "%s" to "%s".',
$source,
$target_dir . '/source.txt'
));

(new File($source))->move($target_dir);
}
}
Loading
Loading