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
47 changes: 47 additions & 0 deletions src/File/FailedToCreateFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Php\Pie\File;

use Php\Pie\Util\CaptureErrors;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessFailedException;

use function array_column;
use function implode;
use function sprintf;

/** @psalm-import-type CapturedErrorList from CaptureErrors */
class FailedToCreateFile extends RuntimeException
{
/** @param CapturedErrorList $recorded */
public static function fromTouchErrors(string $filename, array $recorded): self
{
return new self(sprintf(
"Failed to create file %s.\n\nErrors:\n - %s",
$filename,
implode("\n - ", array_column($recorded, 'message')),
));
}

public static function fromNoPermissions(string $filename): self
{
return new self(sprintf(
'Failed to create file %s as PIE does not have enough permissions',
$filename,
));
}

public static function fromSudoTouchProcessFailed(string $filename, ProcessFailedException $processFailed): self
{
return new self(
sprintf(
'Failed to create file %s using sudo touch: %s',
$filename,
$processFailed->getMessage(),
),
previous: $processFailed,
);
}
}
8 changes: 8 additions & 0 deletions src/File/FailedToWriteFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,12 @@ public static function fromFilePutContentErrors(string $filename, array $recorde
implode("\n - ", array_column($recorded, 'message')),
));
}

public static function fromNoPermissions(string $filename): self
{
return new self(sprintf(
'Failed to write file %s as PIE does not have enough permissions',
$filename,
));
}
}
58 changes: 58 additions & 0 deletions src/File/SudoCreate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Php\Pie\File;

use Php\Pie\Util\CaptureErrors;
use Php\Pie\Util\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;

use function dirname;
use function file_exists;
use function is_writable;
use function touch;

final class SudoCreate
{
public static function file(string $filename): void
{
/**
* Note: strictly speaking, `touch` is a command to:
*
* > Update the access and modification times of each FILE to the current time.
*
* But, the way we are using `touch` is to just create the file, hence
* the class naming is to reflect that. So; if the file already exists
* we can exit early (as we're not actually interested in updating the
* access/modification times of the file currently).
*/
if (file_exists($filename)) {
return;
}

if (is_writable(dirname($filename))) {
$capturedErrors = [];
$touchSuccessful = CaptureErrors::for(
static fn () => touch($filename),
$capturedErrors,
);

if (! $touchSuccessful) {
throw FailedToCreateFile::fromTouchErrors($filename, $capturedErrors);
}

return;
}

if (! Sudo::exists()) {
throw FailedToCreateFile::fromNoPermissions($filename);
}

try {
Process::run([Sudo::find(), 'touch', $filename]);
} catch (ProcessFailedException $processFailedException) {
throw FailedToCreateFile::fromSudoTouchProcessFailed($filename, $processFailedException);
}
}
}
66 changes: 35 additions & 31 deletions src/File/SudoFilePut.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,61 +6,65 @@

use Php\Pie\Util\CaptureErrors;
use Php\Pie\Util\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;

use function dirname;
use function file_exists;
use function file_put_contents;
use function fileperms;
use function is_string;
use function is_writable;
use function sprintf;
use function substr;
use function sys_get_temp_dir;
use function tempnam;

/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
final class SudoFilePut
{
public static function contents(string $filename, string $content): void
{
$didChangePermissions = false;
if (file_exists($filename)) {
$previousPermissions = substr(sprintf('%o', fileperms($filename)), -4);
$fileWritable = file_exists($filename) && is_writable($filename);
$pathWritable = ! file_exists($filename) && file_exists(dirname($filename)) && is_writable(dirname($filename));

$didChangePermissions = self::attemptToMakeFileEditable($filename);
}
if ($fileWritable || $pathWritable) {
$capturedErrors = [];
$writeSuccessful = CaptureErrors::for(
static fn () => file_put_contents($filename, $content),
$capturedErrors,
);

$capturedErrors = [];
$writeSuccessful = CaptureErrors::for(
static fn () => file_put_contents($filename, $content),
$capturedErrors,
);
if ($writeSuccessful === false) {
throw FailedToWriteFile::fromFilePutContentErrors($filename, $capturedErrors);
}

if ($writeSuccessful === false) {
throw FailedToWriteFile::fromFilePutContentErrors($filename, $capturedErrors);
return;
}

if (! isset($previousPermissions) || ! is_string($previousPermissions) || ! $didChangePermissions || ! Sudo::exists()) {
return;
if (! Sudo::exists()) {
throw FailedToWriteFile::fromNoPermissions($filename);
}

Process::run([Sudo::find(), 'chmod', $previousPermissions, $filename]);
self::writeWithSudo($filename, $content);
}

private static function attemptToMakeFileEditable(string $filename): bool
private static function writeWithSudo(string $filename, string $content): void
{
if (! Sudo::exists()) {
return false;
$tempFilename = tempnam(sys_get_temp_dir(), 'pie_tmp_');
if ($tempFilename === false) {
throw FailedToWriteFile::fromNoPermissions($filename);
}

if (! is_writable($filename)) {
try {
Process::run([Sudo::find(), 'chmod', '0777', $filename]);
$capturedErrors = [];
$writeSuccessful = CaptureErrors::for(
static fn () => file_put_contents($tempFilename, $content),
$capturedErrors,
);

if ($writeSuccessful === false) {
throw FailedToWriteFile::fromFilePutContentErrors($tempFilename, $capturedErrors);
}

return true;
} catch (ProcessFailedException) {
return false;
}
if (file_exists($filename)) {
Process::run([Sudo::find(), 'chmod', '--reference=' . $filename, $tempFilename]);
Process::run([Sudo::find(), 'chown', '--reference=' . $filename, $tempFilename]);
}

return false;
Process::run([Sudo::find(), 'mv', $tempFilename, $filename]);
}
}
2 changes: 1 addition & 1 deletion src/File/SudoUnlink.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ public static function singleFile(string $filename): void
return;
}

FailedToUnlinkFile::fromNoPermissions($filename);
throw FailedToUnlinkFile::fromNoPermissions($filename);
}
}
4 changes: 2 additions & 2 deletions src/Installing/Ini/OndrejPhpenmod.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Php\Pie\Downloading\DownloadedPackage;
use Php\Pie\File\BinaryFile;
use Php\Pie\File\Sudo;
use Php\Pie\File\SudoCreate;
use Php\Pie\File\SudoUnlink;
use Php\Pie\Platform\TargetPlatform;
use Php\Pie\Util\Process;
Expand All @@ -21,7 +22,6 @@
use function preg_match;
use function rtrim;
use function sprintf;
use function touch;

use const DIRECTORY_SEPARATOR;

Expand Down Expand Up @@ -139,7 +139,7 @@ public function setup(
OutputInterface::VERBOSITY_VERY_VERBOSE,
);
$pieCreatedTheIniFile = true;
touch($expectedIniFile);
SudoCreate::file($expectedIniFile);
}

$addingExtensionWasSuccessful = ($this->checkAndAddExtensionToIniIfNeeded)(
Expand Down
4 changes: 2 additions & 2 deletions src/Installing/Ini/StandardAdditionalPhpIniDirectory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Php\Pie\Downloading\DownloadedPackage;
use Php\Pie\File\BinaryFile;
use Php\Pie\File\Sudo;
use Php\Pie\File\SudoFilePut;
use Php\Pie\File\SudoCreate;
use Php\Pie\File\SudoUnlink;
use Php\Pie\Platform\TargetPlatform;
use Symfony\Component\Console\Output\OutputInterface;
Expand Down Expand Up @@ -87,7 +87,7 @@ public function setup(
OutputInterface::VERBOSITY_VERY_VERBOSE,
);
$pieCreatedTheIniFile = true;
SudoFilePut::contents($expectedIniFile, '');
SudoCreate::file($expectedIniFile);
}

$addingExtensionWasSuccessful = ($this->checkAndAddExtensionToIniIfNeeded)(
Expand Down
42 changes: 42 additions & 0 deletions test/unit/File/SudoCreateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Php\PieUnitTest\File;

use Composer\Util\Filesystem;
use Php\Pie\File\Sudo;
use Php\Pie\File\SudoCreate;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

use function chmod;
use function mkdir;
use function sys_get_temp_dir;
use function uniqid;

use const DIRECTORY_SEPARATOR;

#[CoversClass(SudoCreate::class)]
final class SudoCreateTest extends TestCase
{
public function testSingleFileCreate(): void
{
if (! Sudo::exists()) {
self::markTestSkipped('Cannot test sudo file_put_contents without sudo');
}

$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_sudo_create_', true);
mkdir($path, 0444);
$file = $path . DIRECTORY_SEPARATOR . uniqid('pie_test_file_', true);
self::assertFileDoesNotExist($file);

SudoCreate::file($file);

chmod($path, 0777);
chmod($file, 0777);
self::assertFileExists($file);

(new Filesystem())->remove($path);
}
}
50 changes: 49 additions & 1 deletion test/unit/File/SudoFilePutTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace Php\PieUnitTest\File;

use Composer\Util\Filesystem;
use Php\Pie\File\Sudo;
use Php\Pie\File\SudoFilePut;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

use function chmod;
use function file_get_contents;
use function mkdir;
use function sys_get_temp_dir;
use function touch;
use function uniqid;
Expand All @@ -20,7 +22,32 @@
#[CoversClass(SudoFilePut::class)]
final class SudoFilePutTest extends TestCase
{
public function testSudoFilePutContents(): void
public function testSudoFilePutContentsWithExistingWritableFile(): void
{
$file = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_sudo_file_put_contents_', true);
touch($file);

SudoFilePut::contents($file, 'the content');

self::assertSame('the content', file_get_contents($file));

(new Filesystem())->remove($file);
}

public function testSudoFilePutContentsWithNewFileInWritablePath(): void
{
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_sudo_file_put_contents_', true);
mkdir($path);
$file = $path . DIRECTORY_SEPARATOR . 'testfile';

SudoFilePut::contents($file, 'the content');

self::assertSame('the content', file_get_contents($file));

(new Filesystem())->remove($path);
}

public function testSudoFilePutContentsWithExistingUnwritableFile(): void
{
if (! Sudo::exists()) {
self::markTestSkipped('Cannot test sudo file_put_contents without sudo');
Expand All @@ -34,5 +61,26 @@ public function testSudoFilePutContents(): void

chmod($file, 777);
self::assertSame('the content', file_get_contents($file));

(new Filesystem())->remove($file);
}

public function testSudoFilePutContentsWithNewFileInUnwritablePath(): void
{
if (! Sudo::exists()) {
self::markTestSkipped('Cannot test sudo file_put_contents without sudo');
}

$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_test_sudo_file_put_contents_', true);
mkdir($path);
chmod($path, 0444);
$file = $path . DIRECTORY_SEPARATOR . 'testfile';

SudoFilePut::contents($file, 'the content');

chmod($path, 0777);
self::assertSame('the content', file_get_contents($file));

(new Filesystem())->remove($path);
}
}
Loading