Skip to content

Commit

Permalink
Apply suggestions, change import format version and add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
pyrech committed Jul 12, 2023
1 parent 87fc454 commit 40ca798
Show file tree
Hide file tree
Showing 14 changed files with 146 additions and 68 deletions.
4 changes: 2 additions & 2 deletions doc/14-remote.md
Expand Up @@ -13,15 +13,15 @@ To import functions from a GitHub repository, pass a path to the `import()`
function, formatted like this:

```
github://<user>/<repository>/<version>/<path of the php file to import>
github://<user>/<repository>/<path of the php file to import>@<version>
```

Here is an example:

```php
use function Castor\import;

import('github://pyrech/castor-setup-php/main/castor.php');
import('github://pyrech/castor-setup-php/castor.php@main');

#[AsTask()]
function hello(): void
Expand Down
2 changes: 1 addition & 1 deletion examples/import.php
Expand Up @@ -6,7 +6,7 @@

use function Castor\import;

import('github://pyrech/castor-setup-php/main');
import('github://pyrech/castor-setup-php/castor.php@main');

#[AsTask(description: 'Use a function imported from a remote repository')]
function hello(): void
Expand Down
17 changes: 2 additions & 15 deletions src/GlobalHelper.php
Expand Up @@ -141,28 +141,15 @@ public static function getCache(): CacheItemPoolInterface&CacheInterface
public static function setupDefaultCache(): void
{
if (!isset(self::$cache)) {
self::setCache(new FilesystemAdapter(directory: self::getGlobalDirectory() . '/cache'));
self::setCache(new FilesystemAdapter(directory: self::getHomeDirectory() . '/cache'));
}
}

public static function setHomeDirectory(string $homeDirectory): void
{
self::$homeDirectory = $homeDirectory;
}

/**
* @throws \RuntimeException If the user home could not reliably be determined
*/
public static function getHomeDirectory(): string
{
return self::$homeDirectory ??= PlatformUtil::getUserDirectory();
}

/**
* @throws \RuntimeException If the user home could not reliably be determined
*/
public static function getGlobalDirectory(): string
{
return self::getHomeDirectory() . '/.castor';
return self::$homeDirectory ??= (PlatformUtil::getUserDirectory() . '/.castor');
}
}
2 changes: 1 addition & 1 deletion src/PlatformUtil.php
Expand Up @@ -45,6 +45,6 @@ public static function getUserDirectory(): string
}
}

throw new \RuntimeException('Could not determine user directory');
throw new \RuntimeException('Could not determine user directory.');
}
}
7 changes: 7 additions & 0 deletions src/Remote/Exception/ImportError.php
@@ -0,0 +1,7 @@
<?php

namespace Castor\Remote\Exception;

class ImportError extends \RuntimeException
{
}
7 changes: 7 additions & 0 deletions src/Remote/Exception/InvalidImportUrl.php
@@ -0,0 +1,7 @@
<?php

namespace Castor\Remote\Exception;

class InvalidImportUrl extends \RuntimeException
{
}
1 change: 0 additions & 1 deletion src/Remote/Exception/NotTrusted.php
Expand Up @@ -6,7 +6,6 @@ class NotTrusted extends \RuntimeException
{
public function __construct(
public readonly string $url,
public readonly bool $wasAsked = true,
) {
parent::__construct("The remote resource {$url} is not trusted.");
}
Expand Down
84 changes: 58 additions & 26 deletions src/Remote/Import.php
Expand Up @@ -3,11 +3,13 @@
namespace Castor\Remote;

use Castor\GlobalHelper;
use Castor\Remote\Exception\ImportError;
use Castor\Remote\Exception\InvalidImportUrl;
use Castor\Remote\Exception\NotTrusted;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Component\Process\ExecutableFinder;

use function Castor\cache;
use function Castor\fs;
use function Castor\get_cache;
use function Castor\get_input;
use function Castor\io;
use function Castor\log;
Expand All @@ -16,18 +18,58 @@
/** @internal */
class Import
{
public static function importFunctionsFromGitRepository(string $domain, string $repository, string $version, string $functionPath): string
public static function importFunctions(string $scheme, string $url, bool $dryRun = false): string
{
if ('github' === $scheme) {
if (!preg_match('#^(?<organization>[^/]+)/(?<repository>[^/]+)(?<function_path>[^@]*)@(?<version>.+)$#', $url, $matches)) {
throw new InvalidImportUrl('The import path from GitHub repository must be formatted like this: "github://<organization>/<repository>/<function_path>@<version>".');
}

$path = self::importFunctionsFromGitRepository(
'github.com',
sprintf('%s/%s', $matches['organization'], $matches['repository']),
$matches['version'],
$matches['function_path'] ?? '/castor.php',
$dryRun,
);

log('Using functions from remote resource.', 'info', [
'url' => $path,
]);

return $path;
}

throw new InvalidImportUrl(sprintf('The import scheme "%s" is not supported.', $scheme));
}

private static function importFunctionsFromGitRepository(string $domain, string $repository, string $version, string $functionPath, bool $dryRun): string
{
self::ensureTrustedResource($domain . '/' . $repository);

$dir = GlobalHelper::getGlobalDirectory() . '/remote/' . $domain . '/' . $repository . '/' . $version;
$dir = GlobalHelper::getHomeDirectory() . '/remote/' . $domain . '/' . $repository . '/' . $version;

if (!$dryRun) {
if (fs()->exists($dir . $functionPath)) {
return $dir . $functionPath;
}

if (!is_dir($dir)) {
log("Importing functions in path {$functionPath} from {$domain}/{$repository} (version {$version})");

fs()->remove($dir);
fs()->mkdir($dir);

run(['git', 'clone', "git@{$domain}:{$repository}.git", '--branch', $version, '--depth', '1', '--filter', 'blob:none', '.'], path: $dir, quiet: true);
$git = (new ExecutableFinder())->find('git');

if (!$git) {
throw new ImportError(sprintf('Could not import resources from "%s" because git is not installed.', $domain . '/' . $repository));
}

try {
run(['git', 'clone', "git@{$domain}:{$repository}.git", '--branch', $version, '--depth', '1', '--filter', 'blob:none', '.'], path: $dir, quiet: true);
} catch (\Throwable $t) {
throw new ImportError(sprintf('Could not import resources from "%s" because git operation failed.', $domain . '/' . $repository), 0, $t);
}
}

return $dir . $functionPath;
Expand All @@ -47,29 +89,18 @@ private static function ensureTrustedResource(string $url): void
}

if (false !== $noTrust) {
throw new NotTrusted($url, false);
throw new NotTrusted($url);
}

$trustKey = sprintf('remote.trust.%s', str_replace('/', '.', $url));
$cache = get_cache();
$trustChoiceCache = $cache->getItem($trustKey);

$trustChoice = cache(
$trustKey,
function (ItemInterface $item) {
if ($item->isHit()) {
return $item->get();
}

$item->expiresAfter(-1);

return null;
},
);

if (null !== $trustChoice) {
$trustChoice = TrustEnum::tryFrom($trustChoice);
if (null !== $trustChoiceCache->isHit()) {
$trustChoice = TrustEnum::tryFrom($trustChoiceCache->get());

if (TrustEnum::NEVER === $trustChoice) {
throw new NotTrusted($url, false);
throw new NotTrusted($url);
}

if (TrustEnum::ALWAYS === $trustChoice) {
Expand All @@ -80,7 +111,7 @@ function (ItemInterface $item) {
static $displayTrustWarning = true;

if ($displayTrustWarning) {
$io->warning('Your Castor project tries to import functions from external resources');
$io->warning('Your Castor project tries to import functions from external resources.');

$displayTrustWarning = false;
}
Expand All @@ -99,11 +130,12 @@ function (ItemInterface $item) {
'choice' => $action->value,
]);

cache($trustKey, fn () => $action->value);
$trustChoiceCache->set($action->value);
$cache->save($trustChoiceCache);
}

if (TrustEnum::NOT_NOW === $action || TrustEnum::NEVER === $action) {
throw new NotTrusted($url, true);
throw new NotTrusted($url);
}
}
}
27 changes: 9 additions & 18 deletions src/functions.php
Expand Up @@ -3,6 +3,8 @@
namespace Castor;

use Castor\Console\Application;
use Castor\Remote\Exception\ImportError;
use Castor\Remote\Exception\InvalidImportUrl;
use Castor\Remote\Exception\NotTrusted;
use Castor\Remote\Import;
use Joli\JoliNotif\Notification;
Expand Down Expand Up @@ -488,24 +490,13 @@ function import(string $path): void

if ($scheme) {
try {
if ('github' === $scheme) {
if (!preg_match('#^github://(?<organization>[^/]+)/(?<repository>[^/]+)/(?<version>[^/]+)(?<function_path>.*)$#', $path, $matches)) {
throw fix_exception(new \InvalidArgumentException('The import path from GitHub repository must be formatted like this: "github://<organization>/<repository>/<version>/<function_path>".'));
}

$path = Import::importFunctionsFromGitRepository(
'github.com',
sprintf('%s/%s', $matches['organization'], $matches['repository']),
$matches['version'],
$matches['function_path'] ?? '/castor.php',
);
$path = Import::importFunctions($scheme, mb_substr($path, mb_strlen($scheme) + 3));
} catch (InvalidImportUrl $e) {
throw fix_exception(new \InvalidArgumentException($e->getMessage(), 0, $e));
} catch (ImportError $e) {
log($e->getMessage(), 'warning');

log('Using functions from remote resource.', 'info', [
'url' => $path,
]);
} else {
throw fix_exception(new \InvalidArgumentException(sprintf('The scheme "%s" is not supported.', $scheme)));
}
return;
} catch (NotTrusted $e) {
log('Ignoring functions from untrusted resource.', 'info', [
'url' => $e->url,
Expand Down Expand Up @@ -536,7 +527,7 @@ function import(string $path): void
}
}

// Remove the last frame (the call to run() to display a nice message to the end user
// Remove the last frame (the call to run()) to display a nice message to the end user
function fix_exception(\Exception $exception): \Exception
{
$lastFrame = $exception->getTrace()[0];
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/ContextContextDynamicTest.php
Expand Up @@ -9,7 +9,7 @@ class ContextContextDynamicTest extends TaskTestCase
// context:context
public function test(): void
{
$process = $this->runTask(['context:context', '--context', 'dynamic']);
$process = $this->runTask(['context:context', '--context', 'dynamic', '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/ContextContextPathTest.php
Expand Up @@ -9,7 +9,7 @@ class ContextContextPathTest extends TaskTestCase
// context:context
public function test(): void
{
$process = $this->runTask(['context:context', '--context', 'path']);
$process = $this->runTask(['context:context', '--context', 'path', '--no-trust']);

$this->assertSame(0, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/ImportHelloAskingTrust.php.output.txt
@@ -1,5 +1,5 @@

[WARNING] Your Castor project tries to import functions from external resources
[WARNING] Your Castor project tries to import functions from external resources.

Trust github.com/pyrech/castor-setup-php and import? [not now]:
[0] not now
Expand Down
2 changes: 1 addition & 1 deletion tests/Examples/RunTestFileTest.php
Expand Up @@ -9,7 +9,7 @@ class RunTestFileTest extends TaskTestCase
// run:test-file
public function test(): void
{
$process = $this->runTask(['run:test-file']);
$process = $this->runTask(['run:test-file', '--no-trust']);

$this->assertSame(1, $process->getExitCode());
$this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput());
Expand Down
55 changes: 55 additions & 0 deletions tests/Remote/ImportTest.php
@@ -0,0 +1,55 @@
<?php

namespace Castor\Tests\Remote;

use Castor\GlobalHelper;
use Castor\Remote\Exception\InvalidImportUrl as InvalidImportUrlAlias;
use Castor\Remote\Exception\NotTrusted;
use Castor\Remote\Import;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\NullOutput;

class ImportTest extends TestCase
{
public function testInvalidScheme(): void
{
$this->expectException(InvalidImportUrlAlias::class);
$this->expectExceptionMessage('The import scheme "foobar" is not supported.');

Import::importFunctions('foobar', 'test-url', dryRun: true);
}

public function testInvalidGithub(): void
{
$this->expectException(InvalidImportUrlAlias::class);
$this->expectExceptionMessage('The import path from GitHub repository must be formatted like this: "github://<organization>/<repository>/<function_path>@<version>".');

Import::importFunctions('github', 'test-url', dryRun: true);
}

public function testValidGithubNotTrusted(): void
{
$this->expectException(NotTrusted::class);
$this->expectExceptionMessage('The remote resource github.com/pyrech/castor-setup-php is not trusted.');

GlobalHelper::setInput(new ArgvInput(['castor', '--no-trust']));
GlobalHelper::setOutput(new NullOutput());
GlobalHelper::setupDefaultCache();

Import::importFunctions('github', 'pyrech/castor-setup-php/castor.php@main', dryRun: true);
}

public function testValidGithubTrusted(): void
{
GlobalHelper::setInput(new ArgvInput(['castor', '--trust']));
GlobalHelper::setOutput(new NullOutput());
GlobalHelper::setupDefaultCache();
GlobalHelper::setLogger(new Logger('test'));

$path = Import::importFunctions('github', 'pyrech/castor-setup-php/castor.php@main', dryRun: true);

$this->assertStringContainsString('.castor/remote/github.com/pyrech/castor-setup-php/main/castor.php', $path);
}
}

0 comments on commit 40ca798

Please sign in to comment.