From 31ca0fcb6f0967a928c9540be157ab53cf335584 Mon Sep 17 00:00:00 2001 From: rhertogh Date: Sun, 30 May 2021 18:26:15 +0200 Subject: [PATCH] Fix #18676: Added method `yii\helpers\BaseFileHelper::changeOwnership()` and properties `newFileMode`/`newFileOwnership` in `yii\console\controllers\BaseMigrateController` Co-authored-by: Bizley --- docs/guide/db-migrations.md | 16 ++ framework/CHANGELOG.md | 2 + .../console/controllers/BaseMigrateController.php | 16 ++ framework/helpers/BaseFileHelper.php | 80 ++++++ tests/framework/helpers/FileHelperTest.php | 306 +++++++++++++++++++++ 5 files changed, 420 insertions(+) diff --git a/docs/guide/db-migrations.md b/docs/guide/db-migrations.md index cbc9ef5..326e0f5 100644 --- a/docs/guide/db-migrations.md +++ b/docs/guide/db-migrations.md @@ -187,6 +187,22 @@ class m150101_185401_create_news_table extends Migration A list of all available methods for defining the column types is available in the API documentation of [[yii\db\SchemaBuilderTrait]]. +> Info: The generated file permissions and ownership will be determined by the current environment. This might lead to + inaccessible files. This could, for example, happen when the migration is created within a docker container + and the files are edited on the host. In this case the `newFileMode` and/or `newFileOwnership` of the MigrateController + can be changed. E.g. in the application config: + ```php + [ + 'migrate' => [ + 'class' => 'yii\console\controllers\MigrateController', + 'newFileOwnership' => '1000:1000', # Default WSL user id + 'newFileMode' => 0660, + ], + ], + ]; + ``` ## Generating Migrations diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 8ef7372..7edc54a 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -8,8 +8,10 @@ Yii Framework 2 Change Log - Enh #18628: Added strings "software", and "hardware" to `$specials` array in `yii\helpers\BaseInflector` (kjusupov) - Enh #18653: Added method `yii\helpers\BaseHtml::getInputIdByName()` (WinterSilence) - Enh #18669: Changed visibility of `yii\web\User::checkRedirectAcceptable()` to `public` (rhertogh) +- Enh #18676: Added method `yii\helpers\BaseFileHelper::changeOwnership()` and properties `newFileMode`/`newFileOwnership` in `yii\console\controllers\BaseMigrateController` (rhertogh) - Bug #18678: Fix `yii\caching\DbCache` to use configured cache table name instead of the default one in case of MSSQL varbinary column type detection (aidanbek) + 2.0.42.1 May 06, 2021 --------------------- diff --git a/framework/console/controllers/BaseMigrateController.php b/framework/console/controllers/BaseMigrateController.php index dc94497..7c0aea4 100644 --- a/framework/console/controllers/BaseMigrateController.php +++ b/framework/console/controllers/BaseMigrateController.php @@ -85,6 +85,20 @@ abstract class BaseMigrateController extends Controller */ public $templateFile; /** + * @var int the permission to be set for newly generated migration files. + * This value will be used by PHP chmod() function. No umask will be applied. + * If not set, the permission will be determined by the current environment. + * @since 2.0.43 + */ + public $newFileMode; + /** + * @var string|int the user and/or group ownership to be set for newly generated migration files. + * If not set, the ownership will be determined by the current environment. + * @since 2.0.43 + * @see FileHelper::changeOwnership() + */ + public $newFileOwnership; + /** * @var bool indicates whether the console output should be compacted. * If this is set to true, the individual commands ran within the migration will not be output to the console. * Default is false, in other words the output is fully verbose by default. @@ -663,6 +677,8 @@ abstract class BaseMigrateController extends Controller return ExitCode::IOERR; } + FileHelper::changeOwnership($file, $this->newFileOwnership, $this->newFileMode); + $this->stdout("New migration created successfully.\n", Console::FG_GREEN); } diff --git a/framework/helpers/BaseFileHelper.php b/framework/helpers/BaseFileHelper.php index 4fa96cf..678eff5 100644 --- a/framework/helpers/BaseFileHelper.php +++ b/framework/helpers/BaseFileHelper.php @@ -9,6 +9,7 @@ namespace yii\helpers; use Yii; use yii\base\ErrorException; +use yii\base\Exception; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; @@ -874,4 +875,83 @@ class BaseFileHelper return $options; } + + /** + * Changes the Unix user and/or group ownership of a file or directory, and optionally the mode. + * Note: This function will not work on remote files as the file to be examined must be accessible + * via the server's filesystem. + * Note: On Windows, this function fails silently when applied on a regular file. + * @param string $path the path to the file or directory. + * @param string|array|int|null $ownership the user and/or group ownership for the file or directory. + * When $ownership is a string, the format is 'user:group' where both are optional. E.g. + * 'user' or 'user:' will only change the user, + * ':group' will only change the group, + * 'user:group' will change both. + * When $owners is an index array the format is [0 => user, 1 => group], e.g. `[$myUser, $myGroup]`. + * It is also possible to pass an associative array, e.g. ['user' => $myUser, 'group' => $myGroup]. + * In case $owners is an integer it will be used as user id. + * If `null`, an empty array or an empty string is passed, the ownership will not be changed. + * @param int|null $mode the permission to be set for the file or directory. + * If `null` is passed, the mode will not be changed. + * + * @since 2.0.43 + */ + public static function changeOwnership($path, $ownership, $mode = null) + { + if (!file_exists($path)) { + throw new InvalidArgumentException('Unable to change ownerhip, "' . $path . '" is not a file or directory.'); + } + + if (empty($ownership) && $ownership !== 0 && $mode === null) { + return; + } + + $user = $group = null; + if (!empty($ownership) || $ownership === 0 || $ownership === '0') { + if (is_int($ownership)) { + $user = $ownership; + } elseif (is_string($ownership)) { + $ownerParts = explode(':', $ownership); + $user = $ownerParts[0]; + if (count($ownerParts) > 1) { + $group = $ownerParts[1]; + } + } elseif (is_array($ownership)) { + $ownershipIsIndexed = ArrayHelper::isIndexed($ownership); + $user = ArrayHelper::getValue($ownership, $ownershipIsIndexed ? 0 : 'user'); + $group = ArrayHelper::getValue($ownership, $ownershipIsIndexed ? 1 : 'group'); + } else { + throw new InvalidArgumentException('$ownership must be an integer, string, array, or null.'); + } + } + + if ($mode !== null) { + if (!is_int($mode)) { + throw new InvalidArgumentException('$mode must be an integer or null.'); + } + if (!chmod($path, $mode)) { + throw new Exception('Unable to change mode of "' . $path . '" to "0' . decoct($mode) . '".'); + } + } + if ($user !== null && $user !== '') { + if (is_numeric($user)) { + $user = (int) $user; + } elseif (!is_string($user)) { + throw new InvalidArgumentException('The user part of $ownership must be an integer, string, or null.'); + } + if (!chown($path, $user)) { + throw new Exception('Unable to change user ownership of "' . $path . '" to "' . $user . '".'); + } + } + if ($group !== null && $group !== '') { + if (is_numeric($group)) { + $group = (int) $group; + } elseif (!is_string($group)) { + throw new InvalidArgumentException('The group part of $ownership must be an integer, string or null.'); + } + if (!chgrp($path, $group)) { + throw new Exception('Unable to change group ownership of "' . $path . '" to "' . $group . '".'); + } + } + } } diff --git a/tests/framework/helpers/FileHelperTest.php b/tests/framework/helpers/FileHelperTest.php index 7fd94a9..09da3d2 100644 --- a/tests/framework/helpers/FileHelperTest.php +++ b/tests/framework/helpers/FileHelperTest.php @@ -6,6 +6,7 @@ */ use yii\helpers\FileHelper; +use yii\helpers\VarDumper; use yiiunit\TestCase; /** @@ -937,4 +938,309 @@ class FileHelperTest extends TestCase sort($foundFiles); $this->assertEquals($expectedFiles, $foundFiles); } + + public function testChangeOwnership() + { + if (DIRECTORY_SEPARATOR !== '/') { + $this->markTestSkipped('FileHelper::changeOwnership() fails silently on Windows, nothing to test.'); + } + + if (!extension_loaded('posix')) { + $this->markTestSkipped('posix extension is required.'); + } + + $dirName = 'change_ownership_test_dir'; + $fileName = 'file_1.txt'; + $testFile = $this->testFilePath . DIRECTORY_SEPARATOR . $dirName . DIRECTORY_SEPARATOR . $fileName; + + $currentUserId = posix_getuid(); + $currentUserName = posix_getpwuid($currentUserId)['name']; + $currentGroupId = posix_getgid(); + $currentGroupName = posix_getgrgid($currentGroupId)['name']; + + ///////////// + /// Setup /// + ///////////// + + $this->createFileStructure([ + $dirName => [ + $fileName => 'test 1', + ], + ]); + + // Ensure the test file is created as the current user/group and has a specific file mode + $this->assertFileExists($testFile); + $fileMode = 0770; + @chmod($testFile, $fileMode); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected created test file owner to be current user.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected created test file group to be current group.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be changed.'); + + + ///////////////// + /// File Mode /// + ///////////////// + + // Test file mode + $fileMode = 0777; + FileHelper::changeOwnership($testFile, null, $fileMode); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected file owner to be unchanged.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be changed.'); + + if ($currentUserId !== 0) { + $this->markTestInComplete(__METHOD__ . ' could only run partially, chown() can only to be tested as root user. Current user: ' . $currentUserName); + } + + ////////////////////// + /// User Ownership /// + ////////////////////// + + // Test user ownership as integer + $ownership = 10001; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership, fileowner($testFile), 'Expected file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as numeric string (should be treated as integer) + $ownership = '10002'; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals((int)$ownership, fileowner($testFile), 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as string + $ownership = $currentUserName; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership, posix_getpwuid(fileowner($testFile))['name'], 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as numeric string with trailing colon (should be treated as integer) + $ownership = '10003:'; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals((int)$ownership, fileowner($testFile), 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as string with trailing colon + $ownership = $currentUserName . ':'; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals(substr($ownership, 0, -1), posix_getpwuid(fileowner($testFile))['name'], 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as indexed array (integer value) + $ownership = [10004]; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership[0], fileowner($testFile), 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as indexed array (numeric string value) + $ownership = ['10005']; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals((int)$ownership[0], fileowner($testFile), 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user ownership as associative array (string value) + $ownership = ['user' => $currentUserName]; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership['user'], posix_getpwuid(fileowner($testFile))['name'], 'Expected created test file owner to be changed.'); + $this->assertEquals($currentGroupId, filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + /////////////////////// + /// Group Ownership /// + /////////////////////// + + // Test group ownership as numeric string + $ownership = ':10006'; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected file owner to be unchanged.'); + $this->assertEquals((int)substr($ownership, 1), filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test group ownership as string + $ownership = ':' . $currentGroupName; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected file owner to be unchanged.'); + $this->assertEquals(substr($ownership, 1), posix_getgrgid(filegroup($testFile))['name'], 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test group ownership as associative array (integer value) + $ownership = ['group' => 10007]; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected file owner to be unchanged.'); + $this->assertEquals($ownership['group'], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test group ownership as associative array (numeric string value) + $ownership = ['group' => '10008']; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected file owner to be unchanged.'); + $this->assertEquals((int)$ownership['group'], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test group ownership as associative array (string value) + $ownership = ['group' => $currentGroupName]; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($currentUserId, fileowner($testFile), 'Expected file owner to be unchanged.'); + $this->assertEquals($ownership['group'], posix_getgrgid(filegroup($testFile))['name'], 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + ///////////////////////////////// + /// User- and Group Ownership /// + ///////////////////////////////// + + // Test user and group ownership as numeric string + $ownership = '10009:10010'; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals((int)explode(':', $ownership)[0], fileowner($testFile), 'Expected file owner to be changed.'); + $this->assertEquals((int)explode(':', $ownership)[1], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as string + $ownership = $currentUserName . ':' . $currentGroupName; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals(explode(':', $ownership)[0], posix_getpwuid(fileowner($testFile))['name'], 'Expected file owner to be changed.'); + $this->assertEquals(explode(':', $ownership)[1], posix_getgrgid(filegroup($testFile))['name'], 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as indexed array (integer values) + $ownership = [10011, 10012]; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership[0], fileowner($testFile), 'Expected file owner to be changed.'); + $this->assertEquals($ownership[1], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as indexed array (numeric string values) + $ownership = ['10013', '10014']; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals((int)$ownership[0], fileowner($testFile), 'Expected file owner to be changed.'); + $this->assertEquals((int)$ownership[1], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as indexed array (string values) + $ownership = [$currentUserName, $currentGroupName]; + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership[0], posix_getpwuid(fileowner($testFile))['name'], 'Expected file owner to be changed.'); + $this->assertEquals($ownership[1], posix_getgrgid(filegroup($testFile))['name'], 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as associative array (integer values) + $ownership = ['group' => 10015, 'user' => 10016]; // user/group reversed on purpose + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership['user'], fileowner($testFile), 'Expected file owner to be changed.'); + $this->assertEquals($ownership['group'], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as associative array (numeric string values) + $ownership = ['group' => '10017', 'user' => '10018']; // user/group reversed on purpose + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals((int)$ownership['user'], fileowner($testFile), 'Expected file owner to be changed.'); + $this->assertEquals((int)$ownership['group'], filegroup($testFile), 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + // Test user and group ownership as associative array (string values) + $ownership = ['group' => $currentGroupName, 'user' => $currentUserName]; // user/group reversed on purpose + FileHelper::changeOwnership($testFile, $ownership); + clearstatcache(true, $testFile); + $this->assertEquals($ownership['user'], posix_getpwuid(fileowner($testFile))['name'], 'Expected file owner to be changed.'); + $this->assertEquals($ownership['group'], posix_getgrgid(filegroup($testFile))['name'], 'Expected created test file group to be changed.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected file mode to be unchanged.'); + + /////////////////////////////////////// + /// Mode, User- and Group Ownership /// + /////////////////////////////////////// + + // Test user ownership as integer with file mode + $ownership = '10019:10020'; + $fileMode = 0774; + FileHelper::changeOwnership($testFile, $ownership, $fileMode); + clearstatcache(true, $testFile); + $this->assertEquals(explode(':', $ownership)[0], fileowner($testFile), 'Expected created test file owner to be changed.'); + $this->assertEquals(explode(':', $ownership)[1], filegroup($testFile), 'Expected file group to be unchanged.'); + $this->assertEquals('0'.decoct($fileMode), substr(decoct(fileperms($testFile)), -4), 'Expected created test file mode to be changed.'); + + } + + public function testChangeOwnershipNonExistingUser() + { + $dirName = 'change_ownership_non_existing_user'; + $fileName = 'file_1.txt'; + $testFile = $this->testFilePath . DIRECTORY_SEPARATOR . $dirName . DIRECTORY_SEPARATOR . $fileName; + + $this->createFileStructure([ + $dirName => [ + $fileName => 'test 1', + ], + ]); + + // Test user ownership as integer with file mode (Due to the nature of chown we can't use PHPUnit's `expectException`) + $ownership = 'non_existing_user'; + try { + FileHelper::changeOwnership($testFile, $ownership); + throw new \Exception('FileHelper::changeOwnership() should have thrown error for non existing user.'); + } catch(\Exception $e) { + $this->assertEquals('chown(): Unable to find uid for non_existing_user', $e->getMessage()); + } + } + + /** + * @dataProvider changeOwnershipInvalidArgumentsProvider + * @param bool $useFile + * @param mixed $ownership + * @param mixed $mode + */ + public function testChangeOwnershipInvalidArguments($useFile, $ownership, $mode) + { + $dirName = 'change_ownership_invalid_arguments'; + $fileName = 'file_1.txt'; + $file = $this->testFilePath . DIRECTORY_SEPARATOR . $dirName . DIRECTORY_SEPARATOR . $fileName; + + $this->createFileStructure([ + $dirName => [ + $fileName => 'test 1', + ], + ]); + + $this->expectException('yii\base\InvalidArgumentException'); + FileHelper::changeOwnership($useFile ? $file : null, $ownership, $mode); + } + + public function changeOwnershipInvalidArgumentsProvider() + { + return [ + [false, '123:123', null], + [true, new stdClass(), null], + [true, ['user' => new stdClass()], null], + [true, ['group' => new stdClass()], null], + [true, null, 'test'], + ]; + } }