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