Repository navigation
Expand file tree
/
Copy pathRuntimeManager.php
More file actions
751 lines (601 loc) · 25.2 KB
/
Copy pathRuntimeManager.php
File metadata and controls
751 lines (601 loc) · 25.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
<?php
declare(strict_types=1);
namespace App\Launcher;
use Phar;
use function copy;
use function mkdir;
use function chmod;
use function is_dir;
use function rename;
use function unlink;
use function is_file;
use function getenv;
use function getmypid;
use function dirname;
use function basename;
use function hash_file;
use function is_string;
use function is_array;
use function array_map;
use function array_values;
use function is_writable;
use function json_decode;
use function hash_equals;
use function is_executable;
use function file_get_contents;
use function sys_get_temp_dir;
use function clearstatcache;
use function max;
use function glob;
use function feof;
use function fread;
use function strpos;
use function strlen;
use function fopen;
use function substr;
use function fclose;
use function fseek;
use function is_int;
use function bin2hex;
use function hash_init;
use function hash_final;
use function random_bytes;
use function hash_update_stream;
use function stream_copy_to_stream;
use function stream_filter_append;
use function sprintf;
/**
* Provides a real PHP CLI SAPI binary to commands that need one.
*
* The `hyde` executable is a micro SAPI: it can run the embedded application,
* but it is not a general purpose CLI interpreter, so it cannot serve
* `php -S` and it cannot run a Composer project's own entry point.
*
* To keep the "no PHP required" guarantee, a full static PHP CLI binary is
* shipped inside the executable as a runtime resource. This class extracts it
* to a versioned per-user cache directory, verifies it against the checksum
* recorded at build time, reuses it whenever it is already present and valid,
* and makes sure it is executable.
*
* It never resolves `php` from PATH. The only non-embedded interpreter it will
* ever use is the very CLI process that is already running the code, which
* only happens when the CLI is run from a source checkout during development.
*
* Composer is bundled the same way, and extracted the same way. It is a PHAR
* rather than a binary, so it is run by the runtime above rather than on its
* own, and it is cached by its own version rather than by platform.
*/
final class RuntimeManager
{
/** The directory inside the application root that holds the embedded runtime. */
public const RUNTIME_DIRECTORY = 'runtime';
/** The build manifest describing the embedded runtime. */
public const MANIFEST_FILE = 'runtime.json';
/**
* The Composer archive the executable ships beside the runtime.
*
* Composer is a PHAR rather than a native binary, so the same bundled PHP runs it.
* It is here so that `hyde composer` and `hyde new --composer` work on a machine
* that has neither PHP nor Composer, which is the whole promise of the CLI.
*/
public const COMPOSER_FILE = 'composer.phar';
/** The production Hyde stylesheet used as the Portable asset fallback. */
public const STYLESHEET_FILE = 'app.css';
/**
* The suffix the embedded runtime binary carries inside the archive.
*
* The binary is stored gzipped, which roughly halves the size of the executable.
* The checksum recorded in the manifest is of the *decompressed* binary, so
* verification covers exactly the bytes that are executed.
*/
public const RUNTIME_SUFFIX = '.gz';
/**
* The exact opening bytes of the application archive's stub.
*
* The build writes this verbatim, so finding it in the executable locates the
* first byte of the archive without relying on any recorded offset.
*/
public const PAYLOAD_PREFIX = "#!/usr/bin/env php\n<?php // ";
/**
* The marker the application archive's stub begins with.
*
* The offset recorded at build time is only a hint: it is measured against the
* micro SAPI binary before the two are concatenated, and anything that later
* changes the length of that prefix — code signing, stripping, an embedded
* ini, a Windows resource section — would silently invalidate it. The marker
* lets the offset be verified, and recovered, at runtime.
*/
public const PAYLOAD_MARKER = 'HYDE_PHAR_PAYLOAD';
private ?string $resolved = null;
private ?string $resolvedArchive = null;
private ?string $resolvedComposer = null;
private readonly Platform $platform;
public function __construct(?Platform $platform = null, private readonly ?string $applicationRoot = null)
{
$this->platform = $platform ?? Platform::current();
}
public static function make(): self
{
return new self();
}
/**
* Get the path to a usable PHP CLI binary, extracting the embedded runtime if needed.
*
* @throws \App\Launcher\LauncherException If no CLI runtime can be provided.
*/
public function path(): string
{
return $this->resolved ??= $this->resolve();
}
public function hasEmbeddedRuntime(): bool
{
return is_file($this->manifestPath()) && is_file($this->embeddedBinaryPath());
}
/** @return array{version: string, platform: string, checksum: string, filename: string} */
public function manifest(): array
{
$manifest = json_decode((string) @file_get_contents($this->manifestPath()), true);
if (! is_array($manifest) || ! isset($manifest['version'], $manifest['checksum'])) {
throw new LauncherException('The embedded PHP runtime manifest is missing or malformed. This executable is corrupt; please reinstall it.');
}
return [
'version' => (string) $manifest['version'],
'platform' => (string) ($manifest['platform'] ?? $this->platform->slug()),
'checksum' => (string) $manifest['checksum'],
'filename' => (string) ($manifest['filename'] ?? $this->platform->runtimeFilename()),
];
}
/**
* The path to a PHAR archive containing the application, readable by a plain PHP CLI.
*
* A micro SAPI executable is a native binary with the archive appended to it, which
* the PHAR extension cannot open. Any child process that needs the application —
* the realtime compiler server, most notably — therefore reads it from a copy
* of the archive payload, extracted next to the PHP runtime and verified
* against the executable on every run.
*/
public function archivePath(): string
{
if ($this->resolvedArchive !== null) {
return $this->resolvedArchive;
}
$running = Phar::running(false);
if (PHP_SAPI !== 'micro') {
// A plain PHAR, or a source checkout, can be read as-is.
return $this->resolvedArchive = ($running ?: $this->applicationRoot());
}
return $this->resolvedArchive = $this->extractArchive($running);
}
/** @throws \App\Launcher\LauncherException */
private function extractArchive(string $executable): string
{
$offset = $this->locatePayload($executable, $this->payloadOffset());
$checksum = $this->hashRange($executable, $offset);
$manifest = $this->manifest();
$directory = $this->cacheDirectory($manifest);
$target = sprintf('%s/app-%s.phar', $directory, substr($checksum, 0, 16));
clearstatcache(true, $target);
if ($this->isValid($target, $checksum)) {
return $target;
}
$this->ensureDirectoryExists($directory);
$temporary = sprintf('%s/.app.%s.%s.phar', $directory, (string) getmypid(), bin2hex(random_bytes(6)));
$this->copyRange($executable, $offset, $temporary);
if (! $this->isValid($temporary, $checksum)) {
@unlink($temporary);
throw new LauncherException('The bundled application archive failed its checksum verification. This executable may be corrupt or truncated; please reinstall it.');
}
$this->install($temporary, $target, $checksum);
$this->pruneStaleArchives($directory, $target);
return $target;
}
/** The byte offset at which the application archive begins inside the executable. */
public function payloadOffset(): int
{
$manifest = json_decode((string) @file_get_contents($this->manifestPath()), true);
$offset = is_array($manifest) ? ($manifest['payload_offset'] ?? null) : null;
if (! is_int($offset) || $offset <= 0) {
throw new LauncherException('The embedded runtime manifest does not record where the application archive begins. This executable is corrupt; please reinstall it.');
}
return $offset;
}
/**
* Confirm the recorded offset really is where the archive begins, and find it if it is not.
*
* @throws \App\Launcher\LauncherException If the archive cannot be found at all.
*/
public function locatePayload(string $executable, int $hint): int
{
if ($this->markerIsAt($executable, $hint)) {
return $hint;
}
$found = $this->searchForMarker($executable);
if ($found === null) {
throw new LauncherException('The application archive could not be located inside this executable. It may be corrupt or truncated; please reinstall it.');
}
return $found;
}
private function markerIsAt(string $executable, int $offset): bool
{
$handle = @fopen($executable, 'rb');
if ($handle === false) {
return false;
}
$expected = self::PAYLOAD_PREFIX.self::PAYLOAD_MARKER;
$found = @fseek($handle, $offset) === 0
&& (string) fread($handle, strlen($expected)) === $expected;
fclose($handle);
return $found;
}
/** Scan the executable for the archive marker, reading in overlapping chunks. */
private function searchForMarker(string $executable): ?int
{
$handle = @fopen($executable, 'rb');
if ($handle === false) {
return null;
}
$needle = self::PAYLOAD_PREFIX.self::PAYLOAD_MARKER;
$chunkSize = 1 << 20;
$overlap = strlen($needle);
$offset = 0;
$carry = '';
while (! feof($handle)) {
$chunk = (string) fread($handle, $chunkSize);
$buffer = $carry.$chunk;
$position = strpos($buffer, $needle);
if ($position !== false) {
fclose($handle);
return max(0, $offset - strlen($carry) + $position);
}
$offset += strlen($chunk);
$carry = substr($buffer, -$overlap);
}
fclose($handle);
return null;
}
private function hashRange(string $path, int $offset): string
{
$handle = @fopen($path, 'rb');
if ($handle === false || @fseek($handle, $offset) !== 0) {
throw new LauncherException("Unable to read the application archive from $path.");
}
$context = hash_init('sha256');
hash_update_stream($context, $handle);
fclose($handle);
return hash_final($context);
}
private function copyRange(string $path, int $offset, string $destination): void
{
$source = @fopen($path, 'rb');
$target = @fopen($destination, 'wb');
if ($source === false || $target === false || @fseek($source, $offset) !== 0) {
if ($source !== false) {
fclose($source);
}
if ($target !== false) {
fclose($target);
}
throw new LauncherException("Unable to extract the application archive to $destination. Check that the directory is writable.");
}
stream_copy_to_stream($source, $target);
fclose($source);
fclose($target);
}
/** Remove archives left behind by previous versions of the executable. */
private function pruneStaleArchives(string $directory, string $keep): void
{
foreach (glob($directory.'/app-*.phar') ?: [] as $archive) {
if ($archive !== $keep) {
@unlink($archive);
}
}
}
/** The version of the PHP runtime this executable ships, or the running version when there is none. */
public function version(): string
{
return $this->hasEmbeddedRuntime() ? $this->manifest()['version'] : PHP_VERSION;
}
/** Is the PHP interpreter currently executing this code the one bundled in the executable? */
public function isBundled(): bool
{
return PHP_SAPI === 'micro';
}
private function resolve(): string
{
if ($this->hasEmbeddedRuntime()) {
return $this->extract();
}
// Running from a source checkout: the interpreter that is already running this
// code is a real CLI SAPI, so we can reuse it. This branch is unreachable in a
// released executable, which always carries an embedded runtime.
if (PHP_SAPI === 'cli' && is_file(PHP_BINARY)) {
return PHP_BINARY;
}
throw new LauncherException(<<<'TEXT'
This command needs a PHP CLI runtime, but this executable does not contain one.
The `hyde` executable normally embeds a PHP runtime for exactly this purpose.
Reinstall the official release for your platform, or run the CLI from a source
checkout using a PHP CLI binary.
TEXT);
}
/**
* Extract the embedded runtime into the user cache, or reuse a valid extraction.
*
* @throws \App\Launcher\LauncherException
*/
public function extract(): string
{
$manifest = $this->manifest();
return $this->extractResource(
$this->embeddedBinaryPath(),
$this->cacheDirectory($manifest).'/'.$manifest['filename'],
$manifest['checksum'],
'PHP runtime',
executable: true
);
}
/**
* Get the path to the bundled Composer archive, extracting it if needed.
*
* @throws \App\Launcher\LauncherException If the executable ships no Composer.
*/
public function composerPath(): string
{
return $this->resolvedComposer ??= $this->extractComposer();
}
public function hasBundledComposer(): bool
{
return is_file($this->embeddedComposerPath()) && $this->composerManifest() !== null;
}
/**
* What the build recorded about the bundled Composer, or null when none was bundled.
*
* This is deliberately a separate reading of the manifest rather than a wider
* {@see self::manifest()}: the PHP runtime is what the executable cannot work
* without, and describing it must not start depending on Composer being there.
*
* @return array{version: string, filename: string, checksum: string, patches: list<string>}|null
*/
public function composerManifest(): ?array
{
if (! is_file($this->manifestPath())) {
return null;
}
$manifest = json_decode((string) @file_get_contents($this->manifestPath()), true);
$composer = is_array($manifest) ? ($manifest['composer'] ?? null) : null;
if (! is_array($composer) || ! isset($composer['version'], $composer['checksum'])) {
return null;
}
return [
'version' => (string) $composer['version'],
'filename' => (string) ($composer['filename'] ?? self::COMPOSER_FILE),
'checksum' => (string) $composer['checksum'],
// What this build changed in the published archive. Empty is the goal.
'patches' => array_values(array_map('strval', is_array($composer['patches'] ?? null) ? $composer['patches'] : [])),
];
}
/**
* The patches this executable carries against the Composer release it ships.
*
* The bundled Composer is not always the published archive byte for byte: where a
* Composer bug stops the CLI working on one of its platforms, the build carries the
* minimum change needed. What was changed is recorded rather than left to be
* discovered, so `hyde info -v` can say so.
*
* @return list<string>
*/
public function composerPatches(): array
{
return $this->composerManifest()['patches'] ?? [];
}
/** The version of Composer this executable ships, if it ships one. */
public function composerVersion(): ?string
{
return $this->composerManifest()['version'] ?? null;
}
/** @throws \App\Launcher\LauncherException */
private function extractComposer(): string
{
$manifest = $this->composerManifest();
if ($manifest === null || ! is_file($this->embeddedComposerPath())) {
throw new LauncherException(<<<'TEXT'
This executable does not bundle Composer.
Released executables ship a Composer of their own, so this is either a build
made without one, or a source checkout. Install Composer, or use an official
release for your platform.
TEXT);
}
return $this->extractResource(
$this->embeddedComposerPath(),
$this->composerCacheDirectory($manifest).'/'.$manifest['filename'],
$manifest['checksum'],
'Composer'
);
}
/**
* Extract one gzipped resource out of the executable, or reuse a valid extraction.
*
* The checksum recorded at build time is of the decompressed file, so verification
* covers exactly the bytes that will be run.
*
* @throws \App\Launcher\LauncherException
*/
private function extractResource(string $source, string $target, string $checksum, string $label, bool $executable = false): string
{
$directory = dirname($target);
clearstatcache(true, $target);
if ($this->isValid($target, $checksum)) {
if ($executable) {
$this->ensureExecutable($target);
}
return $target;
}
$this->ensureDirectoryExists($directory);
$temporary = sprintf('%s/.%s.%s.%s', $directory, basename($target), (string) getmypid(), bin2hex(random_bytes(6)));
$this->decompress($source, $temporary, $label);
if (! $this->isValid($temporary, $checksum)) {
@unlink($temporary);
throw new LauncherException("The bundled $label failed its checksum verification. This executable may be corrupt or truncated; please reinstall it.");
}
if ($executable) {
$this->ensureExecutable($temporary);
}
$this->install($temporary, $target, $checksum, $label);
return $target;
}
/**
* Move the verified temporary file into place.
*
* A rename is atomic on POSIX systems, so concurrent extractions cannot observe a
* partial file. On Windows a rename over an existing file fails, and the target
* may additionally be locked by another Hyde process that is currently running
* it, so the stale file is moved aside first and cleaned up opportunistically.
*/
private function install(string $temporary, string $target, string $checksum, string $label = 'PHP runtime'): void
{
if (@rename($temporary, $target)) {
return;
}
clearstatcache(true, $target);
// Another process may have finished extracting the very same runtime while we
// were working. If what is on disk is valid, our copy is simply redundant.
if ($this->isValid($target, $checksum)) {
@unlink($temporary);
return;
}
$stale = $target.'.stale-'.bin2hex(random_bytes(6));
if (@rename($target, $stale) && @rename($temporary, $target)) {
// The stale binary may still be locked by a running process, in which case
// the deletion fails harmlessly and it is cleaned up on a later run.
@unlink($stale);
$this->ensureExecutable($target);
return;
}
@unlink($temporary);
throw new LauncherException("Unable to install the bundled $label at $target. Another process may be using it, or the directory may be read-only.");
}
/**
* Copy the gzipped runtime out of the archive, inflating it as it is written.
*
* @throws \App\Launcher\LauncherException
*/
private function decompress(string $source, string $destination, string $label = 'PHP runtime'): void
{
$in = @fopen($source, 'rb');
$out = @fopen($destination, 'wb');
if ($in === false || $out === false) {
if ($in !== false) {
fclose($in);
}
if ($out !== false) {
fclose($out);
}
throw new LauncherException("Unable to extract the bundled $label to $destination. Check that the directory is writable.");
}
// A window of 31 selects gzip framing rather than raw deflate.
stream_filter_append($out, 'zlib.inflate', STREAM_FILTER_WRITE, ['window' => 31]);
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
}
private function isValid(string $path, string $checksum): bool
{
if (! is_file($path)) {
return false;
}
$actual = @hash_file('sha256', $path);
return is_string($actual) && hash_equals($checksum, $actual);
}
/**
* Make the extracted runtime executable, where the host has such a thing.
*
* The question is about the filesystem the binary was just written to, so it is asked
* of the *host* rather than of `$this->platform`, which describes the runtime being
* extracted. The two are the same in a released executable. They differ under test,
* where a Linux runtime is extracted on whatever machine the suite runs on, and
* asking the wrong one turns "this host has no executable bit" into a hard
* failure: no file NTFS holds is `is_executable()` unless it ends in .exe.
*/
private function ensureExecutable(string $path): void
{
if (Platform::current()->isWindows()) {
return; // Windows has no executable bit; the .exe extension is what matters.
}
if (! is_executable($path)) {
@chmod($path, 0755);
clearstatcache(true, $path);
if (! is_executable($path)) {
throw new LauncherException("The bundled PHP runtime at $path could not be made executable.");
}
}
}
private function ensureDirectoryExists(string $directory): void
{
if (is_dir($directory)) {
if (! is_writable($directory)) {
throw new LauncherException("The Hyde runtime cache directory at $directory is not writable.");
}
return;
}
if (! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
throw new LauncherException("Unable to create the Hyde runtime cache directory at $directory.");
}
}
/** @param array{version: string, platform: string, checksum: string, filename: string} $manifest */
public function cacheDirectory(array $manifest): string
{
return sprintf('%s/hyde/runtime/%s/%s', $this->cacheRoot(), $manifest['version'], $manifest['platform']);
}
/**
* Where the bundled Composer is extracted to.
*
* Keyed by its own version, and not by the platform: a PHAR is the same file
* everywhere, and the runtime that runs it is chosen separately.
*
* @param array{version: string, filename: string, checksum: string} $manifest
*/
public function composerCacheDirectory(array $manifest): string
{
return sprintf('%s/hyde/composer/%s', $this->cacheRoot(), $manifest['version']);
}
/** The per-user cache root, following platform conventions and the XDG specification. */
public function cacheRoot(): string
{
$override = getenv('HYDE_CACHE_DIR');
if (is_string($override) && $override !== '') {
return Project::canonicalize($override);
}
// Unlike the executable bit, this asks the platform the runtime was built for. It
// only chooses a directory, so a fake platform picks a conventional path rather
// than failing — which is what lets the Windows branch be covered anywhere.
if ($this->platform->isWindows()) {
$appData = getenv('LOCALAPPDATA') ?: getenv('APPDATA');
return Project::canonicalize(is_string($appData) && $appData !== '' ? $appData : sys_get_temp_dir());
}
$xdg = getenv('XDG_CACHE_HOME');
if (is_string($xdg) && $xdg !== '') {
return Project::canonicalize($xdg);
}
$home = getenv('HOME');
if (is_string($home) && $home !== '') {
return Project::canonicalize($home).'/.cache';
}
return Project::canonicalize(sys_get_temp_dir()).'/hyde-cache';
}
public function applicationRoot(): string
{
return $this->applicationRoot ?? dirname(__DIR__, 2);
}
public function embeddedBinaryPath(): string
{
return $this->applicationRoot().'/'.self::RUNTIME_DIRECTORY.'/'.$this->platform->runtimeFilename().self::RUNTIME_SUFFIX;
}
public function embeddedComposerPath(): string
{
return $this->applicationRoot().'/'.self::RUNTIME_DIRECTORY.'/'.self::COMPOSER_FILE.self::RUNTIME_SUFFIX;
}
public function manifestPath(): string
{
return $this->applicationRoot().'/'.self::RUNTIME_DIRECTORY.'/'.self::MANIFEST_FILE;
}
}