parent
49c06a89c8
commit
687b7ac6cb
6 changed files with 337 additions and 101 deletions
@ -0,0 +1,105 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* This file is part of TypePHP(AOT). |
||||||
|
* |
||||||
|
* @link https://www.swoole.com/aot/ |
||||||
|
* @contact service@swoole.com |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace TypePhp\Tests\Build; |
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use TypePhp\Build\EmbeddedArchive; |
||||||
|
use TypePhp\Build\EmbeddedArchiveBuilder; |
||||||
|
use TypePhp\Build\EmbeddedTableRenderer; |
||||||
|
|
||||||
|
/** |
||||||
|
* @internal |
||||||
|
* @coversNothing |
||||||
|
*/ |
||||||
|
final class EmbeddedArchiveBuilderTest extends TestCase |
||||||
|
{ |
||||||
|
private string $directory; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->directory = sys_get_temp_dir() . '/typephp-embedded-archive-' . bin2hex(random_bytes(6)); |
||||||
|
mkdir($this->directory, 0777, true); |
||||||
|
} |
||||||
|
|
||||||
|
protected function tearDown(): void |
||||||
|
{ |
||||||
|
$iterator = new \RecursiveIteratorIterator( |
||||||
|
new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), |
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST, |
||||||
|
); |
||||||
|
foreach ($iterator as $entry) { |
||||||
|
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||||
|
} |
||||||
|
rmdir($this->directory); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFilesAndOpcodesShareOneDeterministicArchive(): void |
||||||
|
{ |
||||||
|
$first = $this->write('first.txt', 'alpha'); |
||||||
|
$second = $this->write('second.bin', "\0beta"); |
||||||
|
$source = $this->write('source.php', '<?php'); |
||||||
|
$blob = $this->write('source.bin', 'opcode'); |
||||||
|
$archivePath = $this->directory . '/cache/archive.bin'; |
||||||
|
|
||||||
|
$archive = (new EmbeddedArchiveBuilder())->build( |
||||||
|
$archivePath, |
||||||
|
[$first, $second], |
||||||
|
[$source => $blob], |
||||||
|
[$source => '@typephp:anon:test'], |
||||||
|
); |
||||||
|
|
||||||
|
self::assertSame("alpha\0betaopcode", file_get_contents($archivePath)); |
||||||
|
self::assertSame([$first => [0, 5], $second => [5, 5]], $archive->fileIndex); |
||||||
|
self::assertSame(['@typephp:anon:test' => [10, 6]], $archive->opcodeIndex); |
||||||
|
self::assertSame(hash_file('sha256', $archivePath), $archive->hash); |
||||||
|
|
||||||
|
file_put_contents($blob, 'new-opcode'); |
||||||
|
$updated = (new EmbeddedArchiveBuilder())->build( |
||||||
|
$archivePath, |
||||||
|
[$first, $second], |
||||||
|
[$source => $blob], |
||||||
|
[$source => '@typephp:anon:test'], |
||||||
|
); |
||||||
|
self::assertSame("alpha\0betanew-opcode", file_get_contents($archivePath)); |
||||||
|
self::assertSame(['@typephp:anon:test' => [10, 10]], $updated->opcodeIndex); |
||||||
|
} |
||||||
|
|
||||||
|
public function testTableRendererKeepsPlatformSpecificArchiveAccessSymmetric(): void |
||||||
|
{ |
||||||
|
$archive = new EmbeddedArchive( |
||||||
|
'/tmp/archive.bin', |
||||||
|
'hash', |
||||||
|
['/app/config.json' => [0, 4]], |
||||||
|
['@typephp:anon:test' => [4, 8]], |
||||||
|
); |
||||||
|
$renderer = new EmbeddedTableRenderer(); |
||||||
|
|
||||||
|
$windows = $renderer->render($archive, '8.4.14', true); |
||||||
|
self::assertStringContainsString( |
||||||
|
'typephp_embedded_archive_start = typephp_embedded_archive_data()', |
||||||
|
$windows, |
||||||
|
); |
||||||
|
self::assertStringContainsString('{"@typephp:anon:test", typephp_embedded_archive_start + 4, 8}', $windows); |
||||||
|
self::assertStringContainsString('*count = 1; return typephp_opcodes;', $windows); |
||||||
|
|
||||||
|
$unix = $renderer->render($archive, '8.4.14', false); |
||||||
|
self::assertStringContainsString( |
||||||
|
'extern "C" const uint8_t typephp_embedded_archive_start[];', |
||||||
|
$unix, |
||||||
|
); |
||||||
|
self::assertStringNotContainsString('typephp_opcode_table_install(void) {}', $unix); |
||||||
|
} |
||||||
|
|
||||||
|
private function write(string $name, string $contents): string |
||||||
|
{ |
||||||
|
$path = $this->directory . '/' . $name; |
||||||
|
file_put_contents($path, $contents); |
||||||
|
return $path; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* This file is part of TypePHP(AOT). |
||||||
|
* |
||||||
|
* @link https://www.swoole.com/aot/ |
||||||
|
* @contact service@swoole.com |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace TypePhp\Build; |
||||||
|
|
||||||
|
final readonly class EmbeddedArchive |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @param array<string, array{int, int}> $fileIndex |
||||||
|
* @param array<string, array{int, int}> $opcodeIndex |
||||||
|
*/ |
||||||
|
public function __construct( |
||||||
|
public string $path, |
||||||
|
public string $hash, |
||||||
|
public array $fileIndex, |
||||||
|
public array $opcodeIndex, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public static function empty(string $path): self |
||||||
|
{ |
||||||
|
return new self($path, '', [], []); |
||||||
|
} |
||||||
|
|
||||||
|
public function isEmpty(): bool |
||||||
|
{ |
||||||
|
return $this->fileIndex === [] && $this->opcodeIndex === []; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,104 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* This file is part of TypePHP(AOT). |
||||||
|
* |
||||||
|
* @link https://www.swoole.com/aot/ |
||||||
|
* @contact service@swoole.com |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace TypePhp\Build; |
||||||
|
|
||||||
|
final class EmbeddedArchiveBuilder |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @param list<string> $files |
||||||
|
* @param array<string, string> $opcodeBlobs source file => blob file |
||||||
|
* @param array<string, string> $opcodeKeys source file => runtime lookup key |
||||||
|
*/ |
||||||
|
public function build( |
||||||
|
string $archivePath, |
||||||
|
array $files, |
||||||
|
array $opcodeBlobs, |
||||||
|
array $opcodeKeys = [], |
||||||
|
): EmbeddedArchive { |
||||||
|
$this->ensureDirectory(dirname($archivePath)); |
||||||
|
$temporaryPath = $archivePath . '.tmp'; |
||||||
|
$stream = fopen($temporaryPath, 'wb'); |
||||||
|
if ($stream === false) { |
||||||
|
throw new \RuntimeException("Cannot create embedded file archive: {$temporaryPath}"); |
||||||
|
} |
||||||
|
|
||||||
|
$fileIndex = []; |
||||||
|
$opcodeIndex = []; |
||||||
|
try { |
||||||
|
foreach ($files as $file) { |
||||||
|
$fileIndex[$file] = $this->append($stream, $file, 'embedded file'); |
||||||
|
} |
||||||
|
foreach ($opcodeBlobs as $sourceFile => $blobFile) { |
||||||
|
$key = $opcodeKeys[$sourceFile] ?? $sourceFile; |
||||||
|
$opcodeIndex[$key] = $this->append($stream, $blobFile, 'opcode blob'); |
||||||
|
} |
||||||
|
} catch (\Throwable $error) { |
||||||
|
fclose($stream); |
||||||
|
@unlink($temporaryPath); |
||||||
|
throw $error; |
||||||
|
} |
||||||
|
|
||||||
|
if (!fclose($stream)) { |
||||||
|
@unlink($temporaryPath); |
||||||
|
throw new \RuntimeException("Cannot close embedded file archive: {$temporaryPath}"); |
||||||
|
} |
||||||
|
|
||||||
|
$temporaryHash = hash_file('sha256', $temporaryPath); |
||||||
|
if (!is_string($temporaryHash)) { |
||||||
|
@unlink($temporaryPath); |
||||||
|
throw new \RuntimeException("Cannot hash embedded file archive: {$temporaryPath}"); |
||||||
|
} |
||||||
|
$currentHash = is_file($archivePath) ? hash_file('sha256', $archivePath) : false; |
||||||
|
if ($currentHash === $temporaryHash) { |
||||||
|
if (!unlink($temporaryPath)) { |
||||||
|
throw new \RuntimeException("Cannot remove unchanged embedded archive: {$temporaryPath}"); |
||||||
|
} |
||||||
|
} else { |
||||||
|
if (is_file($archivePath) && !unlink($archivePath)) { |
||||||
|
@unlink($temporaryPath); |
||||||
|
throw new \RuntimeException("Cannot replace embedded file archive: {$archivePath}"); |
||||||
|
} |
||||||
|
if (!rename($temporaryPath, $archivePath)) { |
||||||
|
@unlink($temporaryPath); |
||||||
|
throw new \RuntimeException("Cannot install embedded file archive: {$archivePath}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return new EmbeddedArchive($archivePath, $temporaryHash, $fileIndex, $opcodeIndex); |
||||||
|
} |
||||||
|
|
||||||
|
/** @return array{int, int} */ |
||||||
|
private function append(mixed $output, string $inputPath, string $description): array |
||||||
|
{ |
||||||
|
$offset = ftell($output); |
||||||
|
if (!is_int($offset)) { |
||||||
|
throw new \RuntimeException('Cannot determine embedded archive offset'); |
||||||
|
} |
||||||
|
$input = fopen($inputPath, 'rb'); |
||||||
|
if ($input === false) { |
||||||
|
throw new \RuntimeException("Cannot read {$description}: {$inputPath}"); |
||||||
|
} |
||||||
|
try { |
||||||
|
$length = stream_copy_to_stream($input, $output); |
||||||
|
} finally { |
||||||
|
fclose($input); |
||||||
|
} |
||||||
|
if (!is_int($length)) { |
||||||
|
throw new \RuntimeException("Cannot copy {$description}: {$inputPath}"); |
||||||
|
} |
||||||
|
return [$offset, $length]; |
||||||
|
} |
||||||
|
|
||||||
|
private function ensureDirectory(string $directory): void |
||||||
|
{ |
||||||
|
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||||
|
throw new \RuntimeException("Cannot create embedded archive directory: {$directory}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* This file is part of TypePHP(AOT). |
||||||
|
* |
||||||
|
* @link https://www.swoole.com/aot/ |
||||||
|
* @contact service@swoole.com |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace TypePhp\Build; |
||||||
|
|
||||||
|
final class EmbeddedTableRenderer |
||||||
|
{ |
||||||
|
public function render(EmbeddedArchive $archive, string $phpVersion, bool $windows): string |
||||||
|
{ |
||||||
|
$code = '#include <typephp_opcode_table.h>' . PHP_EOL; |
||||||
|
if (!$archive->isEmpty()) { |
||||||
|
$code .= $windows |
||||||
|
? 'static const uint8_t *const typephp_embedded_archive_start = typephp_embedded_archive_data();' . PHP_EOL |
||||||
|
: 'extern "C" const uint8_t typephp_embedded_archive_start[];' . PHP_EOL; |
||||||
|
} |
||||||
|
$code .= $this->renderTable( |
||||||
|
'typephp_opcode_entry', |
||||||
|
'typephp_opcodes', |
||||||
|
'typephp_project_opcode_table', |
||||||
|
$archive->opcodeIndex, |
||||||
|
); |
||||||
|
$code .= $this->renderTable( |
||||||
|
'typephp_embedded_file_entry', |
||||||
|
'typephp_embedded_files', |
||||||
|
'typephp_project_embedded_file_table', |
||||||
|
$archive->fileIndex, |
||||||
|
); |
||||||
|
$version = json_encode($phpVersion, JSON_THROW_ON_ERROR); |
||||||
|
$code .= 'extern "C" const char *typephp_project_php_version(void) { return ' . $version . '; }' . PHP_EOL; |
||||||
|
if ($archive->isEmpty()) { |
||||||
|
$code .= 'extern "C" void typephp_opcode_table_install(void) {}' . PHP_EOL; |
||||||
|
$code .= 'extern "C" void typephp_opcode_table_uninstall(void) {}' . PHP_EOL; |
||||||
|
} |
||||||
|
return $code; |
||||||
|
} |
||||||
|
|
||||||
|
/** @param array<string, array{int, int}> $index */ |
||||||
|
private function renderTable(string $type, string $name, string $accessor, array $index): string |
||||||
|
{ |
||||||
|
$code = "static const {$type} {$name}[] = {" . PHP_EOL; |
||||||
|
foreach ($index as $path => [$offset, $length]) { |
||||||
|
$encodedPath = json_encode( |
||||||
|
$path, |
||||||
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, |
||||||
|
); |
||||||
|
$code .= " {{$encodedPath}, typephp_embedded_archive_start + {$offset}, {$length}}," . PHP_EOL; |
||||||
|
} |
||||||
|
$code .= ' {nullptr, nullptr, 0},' . PHP_EOL . '};' . PHP_EOL; |
||||||
|
$code .= "extern \"C\" const {$type} *{$accessor}(size_t *count) {" . PHP_EOL; |
||||||
|
$code .= ' *count = ' . count($index) . "; return {$name};" . PHP_EOL . '}' . PHP_EOL; |
||||||
|
return $code; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue