forked from fa/breadcrumb-the-shire
harden: restore gate baseline, split dev/prod PHP profile, atomic module runtime build
Phase A — Gate Reliability: - Fix typography token violations in app-breadcrumb.css - Switch QG-006 from composer cs:check to direct php-cs-fixer - Align all docs/skills with new gate command Phase B — Production Profile: - Multi-stage Dockerfile: dev (xdebug) / prod (no xdebug) - Compose files target explicit build stages Phase C — Module Runtime Hardening: - Atomic staging+swap build in ModuleRuntimePageBuilder - SHA-256 fingerprint from manifest content + page entries - 11 new regression tests for build safety and fingerprint drift Task: STARTERKIT-HARDENING-ONPREM-001 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,10 @@ use RuntimeException;
|
||||
|
||||
/**
|
||||
* Builds a runtime PageRoot by symlinking core and enabled module pages.
|
||||
*
|
||||
* Build uses atomic staging+swap: output is constructed in a temporary staging
|
||||
* directory alongside the live target and only swapped into place after
|
||||
* successful validation. Partial failures cannot leave inconsistent live state.
|
||||
*/
|
||||
final class ModuleRuntimePageBuilder
|
||||
{
|
||||
@@ -28,65 +32,21 @@ final class ModuleRuntimePageBuilder
|
||||
return ['core_entries' => 0, 'module_entries' => 0];
|
||||
}
|
||||
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
|
||||
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
|
||||
// ── Staging: build in a sibling directory, swap on success ────
|
||||
$stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
|
||||
$this->ensureCleanDirectory($stagingDir);
|
||||
|
||||
try {
|
||||
$result = $this->populateDirectory($stagingDir, $corePages, $modules);
|
||||
} catch (\Throwable $e) {
|
||||
$this->removeDirectory($stagingDir);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
// ── Atomic swap ──────────────────────────────────────────────
|
||||
$this->swapIntoLive($stagingDir, $runtimeDir);
|
||||
|
||||
$coreEntries = scandir($corePages);
|
||||
if ($coreEntries === false) {
|
||||
throw new RuntimeException('Cannot scan core pages directory.');
|
||||
}
|
||||
|
||||
$coreCount = 0;
|
||||
foreach ($coreEntries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $corePages . '/' . $entry;
|
||||
$target = $runtimeDir . '/' . $entry;
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$coreCount++;
|
||||
}
|
||||
|
||||
$modulePageCount = 0;
|
||||
foreach ($modules as $manifest) {
|
||||
$modulePagesDir = $manifest->basePath . '/pages';
|
||||
if (!is_dir($modulePagesDir)) {
|
||||
continue;
|
||||
}
|
||||
$entries = scandir($modulePagesDir);
|
||||
if ($entries === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $modulePagesDir . '/' . $entry;
|
||||
$target = $runtimeDir . '/' . $entry;
|
||||
if (file_exists($target) || is_link($target)) {
|
||||
throw new RuntimeException(
|
||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||
);
|
||||
}
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$modulePageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'core_entries' => $coreCount,
|
||||
'module_entries' => $modulePageCount,
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
||||
@@ -108,14 +68,36 @@ final class ModuleRuntimePageBuilder
|
||||
/**
|
||||
* Compute the expected fingerprint for a set of modules.
|
||||
*
|
||||
* The digest includes module IDs, each manifest file content hash,
|
||||
* and top-level page-entry signatures so that manifest-only or
|
||||
* page-structure changes are detected even when the module ID list
|
||||
* is unchanged.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
*/
|
||||
public static function expectedFingerprint(array $modules): string
|
||||
{
|
||||
$ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules);
|
||||
sort($ids);
|
||||
$parts = [];
|
||||
|
||||
return implode(',', $ids);
|
||||
$manifests = $modules;
|
||||
uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id);
|
||||
|
||||
foreach ($manifests as $manifest) {
|
||||
$manifestFile = $manifest->basePath . '/module.php';
|
||||
$manifestHash = is_file($manifestFile)
|
||||
? md5_file($manifestFile)
|
||||
: 'missing';
|
||||
|
||||
$pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages');
|
||||
|
||||
$parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries);
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +123,153 @@ final class ModuleRuntimePageBuilder
|
||||
file_put_contents($file, self::expectedFingerprint($modules));
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Populate a directory with core + module page symlinks.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{core_entries: int, module_entries: int}
|
||||
*/
|
||||
private function populateDirectory(string $targetDir, string $corePages, array $modules): array
|
||||
{
|
||||
$coreEntries = scandir($corePages);
|
||||
if ($coreEntries === false) {
|
||||
throw new RuntimeException('Cannot scan core pages directory.');
|
||||
}
|
||||
|
||||
$coreCount = 0;
|
||||
foreach ($coreEntries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $corePages . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$coreCount++;
|
||||
}
|
||||
|
||||
$modulePageCount = 0;
|
||||
foreach ($modules as $manifest) {
|
||||
$modulePagesDir = $manifest->basePath . '/pages';
|
||||
if (!is_dir($modulePagesDir)) {
|
||||
continue;
|
||||
}
|
||||
$entries = scandir($modulePagesDir);
|
||||
if ($entries === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $modulePagesDir . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
if (file_exists($target) || is_link($target)) {
|
||||
throw new RuntimeException(
|
||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||
);
|
||||
}
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$modulePageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'core_entries' => $coreCount,
|
||||
'module_entries' => $modulePageCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap staging directory into the live runtime path.
|
||||
*
|
||||
* Uses rename() for an atomic filesystem operation (same filesystem).
|
||||
*/
|
||||
private function swapIntoLive(string $stagingDir, string $runtimeDir): void
|
||||
{
|
||||
// Remove existing live target
|
||||
$oldDir = null;
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
} elseif (is_dir($runtimeDir)) {
|
||||
// Move old live dir aside, then remove after swap
|
||||
$oldDir = $runtimeDir . '.old-' . getmypid();
|
||||
if (!@rename($runtimeDir, $oldDir)) {
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
$oldDir = null;
|
||||
}
|
||||
} else {
|
||||
$oldDir = null;
|
||||
}
|
||||
|
||||
// Atomic rename of staging → live
|
||||
if (!@rename($stagingDir, $runtimeDir)) {
|
||||
throw new RuntimeException(
|
||||
"Failed to swap staging directory into live runtime path: {$stagingDir} → {$runtimeDir}"
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up old directory if we moved it aside
|
||||
if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) {
|
||||
$this->removeDirectory($oldDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureCleanDirectory(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
$this->removeDirectoryContents($dir);
|
||||
rmdir($dir);
|
||||
}
|
||||
if (is_link($dir)) {
|
||||
unlink($dir);
|
||||
}
|
||||
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Cannot create staging directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return sorted top-level entry names from a module pages directory.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function topLevelPageEntries(string $pagesDir): array
|
||||
{
|
||||
if (!is_dir($pagesDir)) {
|
||||
return [];
|
||||
}
|
||||
$entries = scandir($pagesDir);
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
$entries = array_values(array_filter(
|
||||
$entries,
|
||||
static fn (string $e): bool => $e !== '.' && $e !== '..',
|
||||
));
|
||||
sort($entries);
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (is_link($dir)) {
|
||||
unlink($dir);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$this->removeDirectoryContents($dir);
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private function removeDirectoryContents(string $dir): void
|
||||
{
|
||||
$entries = scandir($dir);
|
||||
|
||||
Reference in New Issue
Block a user