diff --git a/CHANGELOG.md b/CHANGELOG.md index 769c318..5488a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Generate native `wp-config.php` directly in the WordPress core parent (`public/` for a `public/wp/` layout), eliminating the default root configuration and forwarding loader. WP-CLI, section editing, setup drift and production diagnostics follow the same target. +- Recover literal salts from previous managed native root configurations without executing PHP; retain the root file for rollback. Customized sections, dynamic salt providers, location-dependent salt code and root symlinks require explicit migration. Literal salt edits, legacy profile layouts and alternate configuration targets remain supported. + ## 1.2.4 — 2026-10-05 - Keep compiled environment caches and immutable dumps in OPcache on reads. Invalidate only a mutable cache actually rewritten by this process; unchanged writes do not waste shared memory. diff --git a/docs/adr/0005-compatibility-and-differences.md b/docs/adr/0005-compatibility-and-differences.md index 7c0fac9..6576823 100644 --- a/docs/adr/0005-compatibility-and-differences.md +++ b/docs/adr/0005-compatibility-and-differences.md @@ -7,6 +7,7 @@ The feature union stays available. Differences below are scoped behaviors with s | ID | Source behavior / conflict | Proposed decision and preserved capability | | --- | --- | --- | | D01 | R/D Config validates lazily and often falls back after invalid input; malformed JSON is silently ignored. | Validate every run before writes, with source paths and option errors. Keep Result and extension validators; a compatibility diagnostic identifies formerly ignored invalid settings. | +| D31 | D and earlier native builds use a root configuration plus a core-parent proxy. | Native writes the main configuration directly in the core parent. Both legacy profile layouts and explicitly supplied targets retain their contracts. Stock previous native root files migrate without PHP execution or salt rotation; edited sections and dynamic salts require explicit migration. ADR 0029 and `WpConfigLocationTest` cover the three layouts, migration and refusal before writes. | | D02 | R content-dev default is symlink, D is auto; null/empty collection defaults differ. | Legacy release profile keeps R defaults; native uses D/work-order defaults. Migration emits explicit values where omitting them would change behavior. | | D03 | Documentation says opt-in wins, but R/D factory filters skip-config and custom steps first. CLI rejects list plus opt-in names. | Native implements requested opt-in precedence and list combinations; compatibility profile preserves upstream conflict behavior. Cover each combination, not just flag parsing. | | D04 | VCS position differs between source declaration, effective factory order and the brief. | Native uses the work-order sequence; legacy D runs wpcliconfig then VCS then wpcli. Legacy R keeps its eleven-step automatic sequence and offers the additional VCS capability by explicit selection. See ADR 0001. | diff --git a/docs/adr/0006-runtime-generation.md b/docs/adr/0006-runtime-generation.md index 317f341..cececd1 100644 --- a/docs/adr/0006-runtime-generation.md +++ b/docs/adr/0006-runtime-generation.md @@ -16,4 +16,4 @@ Symfony Dotenv code carries its own MIT attribution and license. If any WP Start Stable generation preserves managed salts, normalizes paths deliberately, retains section edits according to the tested regeneration policy and only writes changed bytes. Test two completely separate runner processes: an in-process cached Salter is not evidence of idempotence. Random generation is injected only in tests and never replaced with predictable production salts. -Native root wp-config plus webroot proxy follows D while compatibility can maintain R placement. Custom templates may opt out of the native bootstrap but must receive explicit diagnostics about guarantees they replace. Generated Site Health information uses a fixed safe field allowlist for paths, environment and cache metadata, never arbitrary environment values. +Native configuration placement is superseded by [ADR 0029](0029-native-core-parent-configuration.md): the main file lives in the WordPress core parent without a default proxy. The `upstream-dev` compatibility profile retains the D root/proxy layout. Custom templates may opt out of the native bootstrap but must receive explicit diagnostics about guarantees they replace. Generated Site Health information uses a fixed safe field allowlist for paths, environment and cache metadata, never arbitrary environment values. diff --git a/docs/adr/0010-managed-config-regeneration-and-dumps.md b/docs/adr/0010-managed-config-regeneration-and-dumps.md index 3fe2b7a..f6d5ad9 100644 --- a/docs/adr/0010-managed-config-regeneration-and-dumps.md +++ b/docs/adr/0010-managed-config-regeneration-and-dumps.md @@ -4,7 +4,7 @@ Status: implementation decision, 2026-09-29; full differential acceptance pendin ## Generated configuration -Native configuration lives at the project root and a webroot proxy points to it when needed. Release compatibility keeps the webroot location. Every target is checked before publication; `--force` can override file protection but cannot replace directories or symlinks. PHP syntax is checked for both main and proxy content before either is published. Files are replaced atomically with mode 0600, and identical content is not rewritten. Deployment must run PHP with read access to these private files. +Native configuration lives in the WordPress core parent without a default proxy, as specified in [ADR 0029](0029-native-core-parent-configuration.md). Release compatibility keeps that location; development compatibility and alternate targets retain their forwarding loader. Every target is checked before publication; `--force` can override file protection but cannot replace directories or symlinks. PHP syntax is checked for both main and any proxy content before either is published. Files are replaced atomically with mode 0600, and identical content is not rewritten. Deployment must run PHP with read access to these private files. The main file retains all 19 section names from the source union. A comment stores hashes of the generated section bodies. On regeneration, a section whose current body differs from the previous generated hash is retained and reported. Untouched sections receive template/settings updates. This protects edits through the section API; arbitrary edits outside sections are not a supported persistence mechanism. A custom template receives a diagnostic that it owns the bootstrap guarantees it replaces, as required by ADR 0006. diff --git a/docs/adr/0029-native-core-parent-configuration.md b/docs/adr/0029-native-core-parent-configuration.md new file mode 100644 index 0000000..10c0b56 --- /dev/null +++ b/docs/adr/0029-native-core-parent-configuration.md @@ -0,0 +1,48 @@ +# ADR 0029: Native core-parent configuration + +Status: implementation decision requested by the project owner on 2026-10-07. + +## Decision + +The native main configuration defaults to `Paths::wpParent('wp-config.php')`. +For Core in `public/wp`, this is `public/wp-config.php`. WordPress finds this +file through its standard parent-directory lookup; a fresh native project has +one generated configuration and no forwarding loader. The parser payload and +dotenv files remain outside the document root. + +`release-3.0.1` retains its core-parent location. `upstream-dev` retains its root +configuration and proxy. Explicitly supplied invocation-context targets still +win and receive a proxy when the target differs from the core parent. WP-CLI +configuration, section editing, setup drift and production diagnostics resolve +the same profile-dependent target. This supersedes the native location in ADRs +0006 and 0010 and records the intentional difference D31. + +## Existing projects + +When the new target is absent or contains Runtime's old generated proxy, a +managed root configuration supplies the previous literal salts. Runtime reads +and tokenizes it without executing PHP, publishes the new main configuration +with rebased generated paths, and retains the original root file for rollback. +It never silently removes a user file. Operators archive the retained root file +after verifying the new configuration. + +Edited managed sections, unmarked root files, symlinks and dynamic salt providers +require explicit migration before generation writes a replacement. An edited +section may depend on `__DIR__`; preserving its bytes while changing its file +location would change behavior. Back up the old files, move the main file to +the core parent, review relative paths, and regenerate its managed sections. +Ordinary regeneration at a fixed location continues to preserve section edits +and dynamic salt sections. Literal salt edits remain safe to migrate, while +`__DIR__` or `__FILE__` in the retained salt section requires explicit migration. +Both valid and dangling root configuration symlinks are refused before writes. +Server configuration denies direct HTTP access to +the generated configuration; default file permissions remain 0600. + +## Verification + +`WpConfigLocationTest` checks native, release and development layouts, +managed-root salt preservation without PHP execution, and refusal before +replacing edited configurations. `WpConfigTest` checks ordinary boot, +separate-process idempotence and section edits. +The WP-CLI target, production checks, real WordPress bootstrap and payload +rollback tests cover the consumers of the generated configuration. diff --git a/docs/adr/README.md b/docs/adr/README.md index 33cac9a..9126534 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,5 +1,7 @@ # Architecture decisions +- [0029: Native core-parent configuration](0029-native-core-parent-configuration.md) + - [0021: Native MU-plugin loading](0021-native-mu-plugin-loading.md) - [0022: Tagged releases and Composer recovery compatibility](0022-release-and-composer-recovery-policy.md) - [0023: Private environment writes and production payloads](0023-env-production.md) diff --git a/docs/compatibility.md b/docs/compatibility.md index 35a7fab..4e987e4 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -23,6 +23,14 @@ The command reads inline `extra.wpstarter`, referenced JSON and root `wpstarter. ## Choose a behavior profile +Native configuration now lives directly in the WordPress core parent, normally +`public/wp-config.php`, without a root file or forwarding loader. Existing stock +native root configurations migrate with their literal salts preserved; Runtime +retains the original root file. Customized sections and dynamic salt providers +require the explicit migration described in [Generated wp-config](wp-config.md). +The `release-3.0.1` core-parent layout and `upstream-dev` root/proxy layout retain +their compatibility contracts. + Throughout Runtime 1.x, outbound WordPress HTTP blocking is explicit opt-in: set `WP_HTTP_BLOCK_EXTERNAL=true` and review `WP_ACCESSIBLE_HOSTS` only when the project requires that restriction. Production/staging and `composer-managed` diff --git a/docs/configuration.md b/docs/configuration.md index 32932e2..e9a0491 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -142,3 +142,5 @@ Steps can read `composer-updated-packages`, `is-composer-install`, `is-composer-update`, `is-runtime-command`, `is-runtime-selected-command` and the derived `wp-config-php-path`. These are invocation context, not writable project settings. Older aliases are listed in [Compatibility](compatibility.md). +The native configuration target is the WordPress core parent, such as +`public/wp-config.php`; `upstream-dev` retains its root target and forwarding loader. diff --git a/docs/deployment.md b/docs/deployment.md index 1a9ad7d..5e371ff 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -72,7 +72,7 @@ Deploy these artifacts together with the reviewed lockfile/application release: | Artifact | Requirement | | --- | --- | -| Generated `wp-config.php` and core-parent proxy | Match the selected profile, path layout and payload | +| Generated core-parent `wp-config.php` | Match the selected profile, path layout and payload; a proxy is only needed for alternate configuration targets | | `var/runtime//` | Include every payload referenced by an active or rollback configuration | | Generated `index.php`, MU loader and optional kernel bootstrap | Keep consistent with installed core/content packages | | `.env.dump.php` when using build dumps | Private per-environment input; PHP-readable; contains secrets | diff --git a/docs/getting-started.md b/docs/getting-started.md index a24d02c..669b385 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -43,12 +43,12 @@ my-site/ ├── composer.json ├── composer.lock ├── .env # private, created in the next step -├── wp-config.php # generated ├── wp-cli.yml # generated ├── vendor/ ├── var/runtime/ # required generated PHP payload └── public/ # web document root ├── index.php # generated + ├── wp-config.php # generated main configuration; no forwarding loader ├── wp/ # Composer-managed WordPress core └── wp-content/ ├── mu-plugins/ diff --git a/docs/maintainers/parity.md b/docs/maintainers/parity.md index 5af684e..0c8a9df 100644 --- a/docs/maintainers/parity.md +++ b/docs/maintainers/parity.md @@ -39,7 +39,7 @@ The package name is `sympress/runtime`. Test IDs below are PHPUnit groups or nam | is | R/D Config/Result::is | Strict comparison; none is(null) true, errored is(null) false. | Config/Result | `PAR-RES-004` | verified | Parity | | not | R/D Config/Result::not | Negation of is including error case. | Config/Result | `PAR-RES-005` | verified | Parity | | either | R/D Config/Result::either | Strict membership across variadic alternatives; errors false. | Config/Result | `PAR-RES-006` | verified | Parity | -| Derived config path | D Requirements::determineWpConfigPath; R WpConfigStep | wp-config-php-path is computed, not a documented user default: root in D unless an existing webroot config contains WordPressEnvBridge; R writes webroot. | Config/RunContext | `PAR-CFG-012` | verified | D13 | +| Derived config path | D Requirements::determineWpConfigPath; R WpConfigStep | wp-config-php-path is computed, not a documented user default: native and R write the WordPress parent; D retains its root configuration and parent proxy. Explicit invocation context still overrides the target. | Config/RunContext; WpConfigLocationTest | `PAR-CFG-012` | verified | D13, D31 | ## Every configuration option @@ -112,7 +112,7 @@ Native default order is the twelve-step sequence in the work order. Actual upstr | Feature | Upstream reference | Behavior and edge cases | Implementation responsibility | Test ID | Status | Difference | | --- | --- | --- | --- | --- | --- | --- | | checkpaths | R/D Step/CheckPathStep | Blocking; create content and missing themes/plugins when appropriate; realpath core wp-settings and vendor autoload; warn env in webroot. | Steps/CheckPathsStep | `PAR-STEP-001` | verified | Parity | -| wpconfig | R/D Step/WpConfigStep | Blocking file-creation; relative paths, all salts/placeholders, root or legacy webroot target and proxy; every generated target guarded. | Steps/WpConfigStep | `PAR-STEP-002` | verified | D07, D13, D20 | +| wpconfig | R/D Step/WpConfigStep | Blocking file-creation; relative paths, all salts/placeholders; native writes one WordPress-parent configuration, D retains the root target and proxy. Managed native root configurations migrate without executing PHP or rotating salts; edited sections require explicit migration. Every generated target is guarded. | Steps/WpConfigStep; WpConfigLocationTest | `PAR-STEP-002` | verified | D07, D13, D20, D31 | | index | R/D Step/IndexStep | Blocking file-creation; webroot index requires core index via shortest relative path, including same-root installation. | Steps/IndexStep | `PAR-STEP-003` | verified | Parity | | flushenvcache | R/D Step/FlushEnvCacheStep | Only when configured env-dir/.env.cached.php exists; remove cache, return failure on removal error; absent file skips. | Steps/FlushEnvCacheStep | `PAR-STEP-004` | verified | Parity | | muloader | R/D Step/MuLoaderStep; Util/MuPluginList | Only when MU entries exist; relative plugin list in generated loader under mu-plugins; overwrites guarded. | Steps/MuLoaderStep | `PAR-STEP-005` | verified | Parity | @@ -191,11 +191,11 @@ Native default order is the twelve-step sequence in the work order. Actual upstr | Salts across reruns | W idempotence; R/D WpConfigStep | Separate processes preserve managed fallback salts and user-provided secrets; missing keys alone generated; no force-triggered rotation. | Generation/SaltStore | `PAR-WP-014` | verified | D07 | | Health info | R/D template DEBUG_INFO | debug_information priority 30 adds dedicated info section; allowlist paths/env/cache/bootstrap/SSL/theme flags only; no DB credentials, salt or arbitrary values. | WordPress/HealthInfo | `PAR-WP-015` | verified | Parity | | Cleanup and bootstrap | R/D template CLEAN_UP, final require | Unset temporary bridge/debug variables; retain global table_prefix; require wp-settings once after sections. | Generation/WpConfig | `PAR-WP-016` | verified | Parity | -| D root proxy and CLI | D wp-config-loader.php; WP_CLI_HACK; wp-cli.yml | Proxy loads root configuration; WP-CLI parses config differently; WP_STARTER_WP_CONFIG_PATH guard prevents duplicate settings bootstrap; no copied lyric/comment hacks. | Generation/WpConfigProxy | `PAR-WP-017` | verified | D13 | +| D root proxy and CLI | D wp-config-loader.php; WP_CLI_HACK; wp-cli.yml | D retains a proxy loading the root configuration; native needs no proxy. WP-CLI parses config differently; WP_STARTER_WP_CONFIG_PATH guard prevents duplicate settings bootstrap for proxy layouts; no copied lyric/comment hacks. | Generation/WpConfigProxy | `PAR-WP-017` | verified | D13, D31 | | Section extraction | R/D WpConfigSectionEditor::sectionContent | NAME : { ... } #@@/NAME with flexible whitespace; query uppercases/trims name, returns trimmed content lines; missing returns empty. | Generation/WpConfigSectionEditor | `PAR-SEC-001` | verified | Parity | | Append and prepend | R/D WpConfigSectionEditor::append/prepend | Insert inside markers, preserve other sections; marker digest includes whitespace-stripped content + caller file/line; repeat same call no duplicate. | Generation/WpConfigSectionEditor | `PAR-SEC-002` | verified | D15 | | Replace and delete | R/D WpConfigSectionEditor::replace/delete | Replace only content, preserve labels/closing markers; delete leaves empty section; escaped dollars/backslashes literal; nonexistent section no-op. | Generation/WpConfigSectionEditor | `PAR-SEC-003` | verified | D15 | -| Section error paths | R/D WpConfigSectionEditor | Missing/empty config and write failures throw; root versus legacy webroot resolved consistently; custom template section availability diagnosed. | Generation/WpConfigSectionEditor | `PAR-SEC-004` | verified | D15 | +| Section error paths | R/D WpConfigSectionEditor | Missing/empty config and write failures throw; native WordPress-parent and legacy targets resolved consistently; custom template section availability diagnosed. | Generation/WpConfigSectionEditor | `PAR-SEC-004` | verified | D15, D31 | ## Database and WP-CLI diff --git a/docs/wp-config.md b/docs/wp-config.md index 8b010f3..4b3dedc 100644 --- a/docs/wp-config.md +++ b/docs/wp-config.md @@ -1,12 +1,29 @@ # Generated wp-config and sections -The `wpconfig` step generates `wp-config.php` at the project root. Run it with: +The native `wpconfig` step generates the main `wp-config.php` in the WordPress +core parent: `public/wp-config.php` when Core is installed in `public/wp/`. +This is the file WordPress discovers directly; native projects need no root +configuration or forwarding loader. Run it with: ```sh vendor/bin/runtime --no-interaction wpconfig ``` -Older compatibility profiles can use a different location; see [Compatibility](compatibility.md). A proxy in the core parent is generated when required. Both files are parsed before publication. Native unmarked files require overwrite approval; protected paths, symlinks and directories retain their documented guards. Existing salts and edited managed sections are preserved during regeneration. +The `release-3.0.1` profile also uses the core parent. `upstream-dev` keeps its +root configuration and core-parent proxy; an explicitly supplied alternate +target still receives a proxy when needed. See [Compatibility](compatibility.md). +Every generated file is parsed before publication. Native unmarked files require +overwrite approval; protected paths, symlinks and directories retain their guards. +Existing salts and edited managed sections are preserved during regeneration. + +When upgrading an older native root layout, Runtime replaces its generated proxy +with the main configuration and recovers literal salts without executing the old +PHP. It retains the old root file for rollback; archive it after verifying the +new configuration. A customized root configuration or dynamic salt provider +requires an explicit migration: back up both files, move the main configuration +to the core parent, review paths relative to `__DIR__`, and regenerate the managed +sections. Keep `.env` and `var/runtime/` outside the public document root. Deny +direct HTTP requests to `wp-config.php` in the web-server configuration. ## What gets loaded diff --git a/src/Console/Doctor.php b/src/Console/Doctor.php index 3efcfa9..d216cee 100644 --- a/src/Console/Doctor.php +++ b/src/Console/Doctor.php @@ -133,7 +133,11 @@ public function inspect(bool $production = false, bool $databaseHealth = false, $record('kernel.boot', 'fail', 'Kernel boot ownership is ambiguous.'); } } - foreach (['configuration' => $this->paths->root('wp-config.php'), 'content' => $this->paths->wpContent(), ...$kernelDirectories] as $id => $path) { + $configuration = $this->config['wp-config-php-path']->unwrapOrFallback($this->config['compatibility-profile']->is('upstream-dev') ? $this->paths->root('wp-config.php') : $this->paths->wpParent('wp-config.php')); + if (!is_string($configuration)) { + throw new RuntimeException('Invalid configuration path.'); + } + foreach (['configuration' => $configuration, 'content' => $this->paths->wpContent(), ...$kernelDirectories] as $id => $path) { $ancestor = $path; while (!file_exists($ancestor) && dirname($ancestor) !== $ancestor) { $ancestor = dirname($ancestor); diff --git a/src/Console/ProductionChecks.php b/src/Console/ProductionChecks.php index c3310b7..ba88569 100644 --- a/src/Console/ProductionChecks.php +++ b/src/Console/ProductionChecks.php @@ -27,7 +27,7 @@ public function inspect(EnvReader $env, bool $dump, ?string $phpUser = null, ?st $record = static function (string $id, ?bool $pass, string $detail) use (&$checks): void { $checks[] = ['id' => $id, 'status' => $pass === null ? 'unknown' : ($pass ? 'pass' : 'fail'), 'detail' => $detail]; }; - $configFile = $this->config['wp-config-php-path']->unwrapOrFallback($this->paths->root('wp-config.php')); + $configFile = $this->config['wp-config-php-path']->unwrapOrFallback($this->config['compatibility-profile']->is('upstream-dev') ? $this->paths->root('wp-config.php') : $this->paths->wpParent('wp-config.php')); $content = is_string($configFile) && is_readable($configFile) ? file_get_contents($configFile) : false; $content = is_string($content) ? $content : ''; [$predefined, $opaque] = $this->bootstrapEvidence($content, $env); @@ -204,7 +204,7 @@ private function bootstrapEvidence(string $content, EnvReader $env): array private function matchesTemplateShape(string $actual, string $template): bool { - $target = $this->config['wp-config-php-path']->unwrapOrFallback($this->paths->root('wp-config.php')); + $target = $this->config['wp-config-php-path']->unwrapOrFallback($this->config['compatibility-profile']->is('upstream-dev') ? $this->paths->root('wp-config.php') : $this->paths->wpParent('wp-config.php')); $directory = is_string($target) ? dirname($target) : $this->paths->root(); $envDirectory = $this->config['env-dir']->unwrapOrFallback($this->paths->root()); $bootstrap = $this->config['env-bootstrap-dir']->unwrapOrFallback($envDirectory); diff --git a/src/Console/SetupDrift.php b/src/Console/SetupDrift.php index 08db0ea..13f7d62 100644 --- a/src/Console/SetupDrift.php +++ b/src/Console/SetupDrift.php @@ -185,7 +185,7 @@ private function inputs(array $steps): string private function outputs(array $steps): array { $targets = []; - $config = $this->option('wp-config-php-path', $this->config['compatibility-profile']->is('release-3.0.1') ? $this->paths->wpParent('wp-config.php') : $this->paths->root('wp-config.php')); + $config = $this->option('wp-config-php-path', $this->config['compatibility-profile']->is('upstream-dev') ? $this->paths->root('wp-config.php') : $this->paths->wpParent('wp-config.php')); $map = [ 'wpconfig' => [$config, $this->paths->wpParent('wp-config.php')], 'index' => [$this->paths->wpParent('index.php')], diff --git a/src/Generation/WpConfigGenerator.php b/src/Generation/WpConfigGenerator.php index f495a5d..36594a2 100644 --- a/src/Generation/WpConfigGenerator.php +++ b/src/Generation/WpConfigGenerator.php @@ -24,7 +24,7 @@ public function __construct(private Config $config, private Paths $paths, privat public function target(): string { - $fallback = $this->config['compatibility-profile']->is('release-3.0.1') ? $this->paths->wpParent('wp-config.php') : $this->paths->root('wp-config.php'); + $fallback = $this->config['compatibility-profile']->is('upstream-dev') ? $this->paths->root('wp-config.php') : $this->paths->wpParent('wp-config.php'); $path = $this->config['wp-config-php-path']->unwrapOrFallback($fallback); if (!is_string($path)) { throw new RuntimeException('Invalid configuration target path.'); @@ -47,6 +47,7 @@ public function generate(bool $force = false, bool $primaryApproved = false): bo if ($existing === false) { throw new RuntimeException('Cannot read existing configuration.'); } + $existing = $this->previousNativeConfiguration($target, $existing); $oldSections = $existing === null ? [] : $this->sections->sections($existing); if ($existing !== null && isset($oldSections['KEYS'])) { // Only the retained KEYS body may contain dynamic definitions. @@ -125,6 +126,40 @@ public function generate(bool $force = false, bool $primaryApproved = false): bo return $proxyContent === null || $this->writer->write($proxy, $proxyContent); } + private function previousNativeConfiguration(string $target, ?string $existing): ?string + { + $previous = $this->paths->root('wp-config.php'); + if (!$this->config['compatibility-profile']->is('native') || $target === $previous || $target !== $this->paths->wpParent('wp-config.php') || (!is_file($previous) && !is_link($previous))) { + return $existing; + } + if ($existing !== null && !str_contains($existing, '// @generated by sympress/runtime wp-config-proxy:v1')) { + return $existing; + } + if (is_link($previous)) { + throw new RuntimeException('Existing root configuration is a symlink. Migrate it explicitly before generating the WordPress-parent configuration.'); + } + $this->boundary->assertWritablePath($previous); + $content = file_get_contents($previous); + if ($content === false || !str_contains($content, '// @generated by sympress/runtime wp-config:v1') || !str_contains($content, '// @sympress-section-hashes: ')) { + throw new RuntimeException('Existing root configuration must be migrated explicitly before generating the WordPress-parent configuration.'); + } + if ($this->sections->merge($content, $content)['preserved'] !== []) { + throw new RuntimeException('Existing root configuration has edited sections. Move it to the WordPress parent and review relative paths before regenerating.'); + } + // Recover literal salts without evaluating the old bootstrap. Dynamic + // providers require an explicit migration because __DIR__ changes. + $this->salts->existingKeys($content); + $keys = $this->sections->sections($content)['KEYS'] ?? ''; + foreach (PhpToken::tokenize('is([T_DIR, T_FILE])) { + throw new RuntimeException('Existing root configuration has location-dependent salt section. Move it to the WordPress parent and review relative paths before regenerating.'); + } + } + $this->io->comment('Migrating managed root configuration to the WordPress parent; the original root file is retained.'); + + return $content; + } + private function stringOption(string $option, string $fallback): string { $value = $this->config[$option]->unwrapOrFallback($fallback); diff --git a/src/Generation/WpConfigSectionEditor.php b/src/Generation/WpConfigSectionEditor.php index c1d60ab..8cd10f3 100644 --- a/src/Generation/WpConfigSectionEditor.php +++ b/src/Generation/WpConfigSectionEditor.php @@ -99,7 +99,7 @@ private function path(): string return $configured; } - return $this->config['compatibility-profile']->is('release-3.0.1') ? $this->paths->wpParent('wp-config.php') : $this->paths->root('wp-config.php'); + return $this->config['compatibility-profile']->is('upstream-dev') ? $this->paths->root('wp-config.php') : $this->paths->wpParent('wp-config.php'); } private function read(): string diff --git a/tests/Contract/GenerationServicesTest.php b/tests/Contract/GenerationServicesTest.php index dfa2e8f..c5069b0 100644 --- a/tests/Contract/GenerationServicesTest.php +++ b/tests/Contract/GenerationServicesTest.php @@ -68,6 +68,8 @@ public function testLegacyWebrootAndMissingConfigErrors(): void $this->write('public/wp-config.php', "sectionContent('FIRST')); $native = new WpConfigSectionEditor($paths, new Config([], new Validator($paths)), new Filesystem()); + self::assertSame('legacy();', $native->sectionContent('FIRST')); + unlink($this->root . '/public/wp-config.php'); $this->expectExceptionMessage('missing or unreadable'); $native->sectionContent('FIRST'); } diff --git a/tests/Integration/DefaultArtifactStepsTest.php b/tests/Integration/DefaultArtifactStepsTest.php index 71ca935..802cc7c 100644 --- a/tests/Integration/DefaultArtifactStepsTest.php +++ b/tests/Integration/DefaultArtifactStepsTest.php @@ -167,7 +167,7 @@ public function testWpCliYamlEscapesPathsAndExecSetsTheActualConfigTarget(): voi self::assertSame("public/word'press", json_decode($path[1], flags: JSON_THROW_ON_ERROR)); $process = new Process([PHP_BINARY, '-r', json_decode($exec[1], flags: JSON_THROW_ON_ERROR) . 'echo getenv("WP_CONFIG_PATH");'], $this->root); $process->mustRun(); - self::assertSame($this->root . '/wp-config.php', $process->getOutput()); + self::assertSame($this->root . '/public/wp-config.php', $process->getOutput()); self::assertSame(0, $this->generate(['wpcliconfig'])->getExitCode()); self::assertSame($content, file_get_contents($file)); $this->write('wp-cli.yml', 'path: user-owned'); diff --git a/tests/Integration/ProductionDoctorTest.php b/tests/Integration/ProductionDoctorTest.php index e46e599..239c89f 100644 --- a/tests/Integration/ProductionDoctorTest.php +++ b/tests/Integration/ProductionDoctorTest.php @@ -15,10 +15,10 @@ final class ProductionDoctorTest extends TemporaryProject private function fixture(): void { $this->write('vendor/autoload.php', 'write('composer.json', json_encode(['extra' => ['sympress-runtime' => ['wordpress-parent-dir' => 'public']]], JSON_THROW_ON_ERROR)); - $this->write('wordpress/wp-load.php', 'configuration(); + $this->write('public/wp/wp-load.php', 'write('public/.keep', ''); - $this->write('wp-content/.keep', ''); + $this->write('public/content/.keep', ''); $this->write('.env', "WP_ENVIRONMENT_TYPE=production\nWP_HOME=https://example.test\nDB_NAME=fixture\nDB_USER=fixture\nDB_HOST=localhost\nDB_PASSWORD=private-credential\nAPP_SECRET=" . str_repeat('test-secret-', 4) . "\nWPDB_ENV_VALID=true\nWPDB_EXISTS=true\nWP_INSTALLED=true\n"); chmod($this->root . '/.env', 0600); $template = file_get_contents(dirname(__DIR__, 2) . '/templates/wp-config.php'); @@ -31,11 +31,17 @@ private function fixture(): void foreach (['DEFAULT_ENV', 'COMPOSER_MANAGED'] as $name) { $config .= $name . ' : {' . strtr($sections[$name], ['{{{COMPOSER_MANAGED}}}' => "'auto'", '{{{PROFILE}}}' => "'native'"]) . '} #@@/' . $name . "\n"; } - $this->write('wp-config.php', $config); - chmod($this->root . '/wp-config.php', 0600); + $this->write('public/wp-config.php', $config); + chmod($this->root . '/public/wp-config.php', 0600); $this->dump(); } + /** @param array $options */ + private function configuration(array $options = []): void + { + $this->write('composer.json', json_encode(['extra' => ['wordpress-install-dir' => 'public/wp', 'wordpress-content-dir' => 'public/content', 'sympress-runtime' => $options]], JSON_THROW_ON_ERROR)); + } + private function dump(): void { $autoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; @@ -99,7 +105,7 @@ public function testBadHttpsDebugAndExplicitHardeningOverridesFail(): void public function testExplicitlyDisabledEnvironmentCacheFailsProductionCheck(): void { $this->fixture(); - $this->write('composer.json', '{"extra":{"sympress-runtime":{"wordpress-parent-dir":"public","cache-env":false}}}'); + $this->configuration(['cache-env' => false]); [$exit, $checks] = $this->diagnose(); self::assertSame(1, $exit); self::assertSame('fail', $checks['production.env-cache']); @@ -190,7 +196,7 @@ public function testMissingDumpPublicSecretsAndInsecurePermissionsFail(): void public function testCommentedOrDefaultSaltsDoNotCountAsPrivateKeys(): void { $this->fixture(); - $this->write('wp-config.php', "write('public/wp-config.php', "diagnose(); self::assertSame(1, $exit); self::assertSame('fail', $checks['production.salts']); @@ -222,7 +228,7 @@ public function testCommandScanIncludesEveryExplicitDotenvFilename(string $name) { $this->fixture(); unlink($this->root . '/.env.dump.php'); - $this->write('composer.json', json_encode(['extra' => ['sympress-runtime' => ['env-file' => $name]]], JSON_THROW_ON_ERROR)); + $this->configuration(['env-file' => $name]); $this->write($name, 'ATTACK=$(touch ' . $this->root . "/shell-executed)\n"); [$exit, $checks] = $this->diagnose(); self::assertSame(1, $exit); @@ -233,7 +239,7 @@ public function testCommandScanIncludesEveryExplicitDotenvFilename(string $name) public function testCommandScanTreatsEnvironmentDirectoryAsLiteralPath(): void { $this->fixture(); - $this->write('composer.json', '{"extra":{"sympress-runtime":{"env-dir":"config/[secrets]"}}}'); + $this->configuration(['env-dir' => 'config/[secrets]']); $this->write('config/[secrets]/.env', 'ATTACK=$(touch ' . $this->root . "/shell-executed)\n"); [$exit, $checks] = $this->diagnose(); self::assertSame(1, $exit); @@ -244,7 +250,7 @@ public function testCommandScanTreatsEnvironmentDirectoryAsLiteralPath(): void public function testUnlistableTraversableEnvironmentDirectoryFailsBeforeParsing(): void { $this->fixture(); - $this->write('composer.json', '{"extra":{"sympress-runtime":{"env-dir":"secrets"}}}'); + $this->configuration(['env-dir' => 'secrets']); $this->write('secrets/.env', 'ATTACK=$(touch ' . $this->root . "/shell-executed)\n"); $directory = $this->root . '/secrets'; chmod($directory, 0111); @@ -265,7 +271,7 @@ public function testEarlyLiteralDefinesOverrideEnvironmentAndUnchangedDefaultSec $this->fixture(); file_put_contents($this->root . '/.env', "WP_DEBUG=false\nDISALLOW_FILE_MODS=true\n", FILE_APPEND); $this->dump(); - $file = $this->root . '/wp-config.php'; + $file = $this->root . '/public/wp-config.php'; $source = file_get_contents($file); self::assertIsString($source); file_put_contents($file, str_replace('fixture(); - $file = $this->root . '/wp-config.php'; + $file = $this->root . '/public/wp-config.php'; $source = file_get_contents($file); self::assertIsString($source); $sideEffect = "file_put_contents(__DIR__ . '/executed', 'never');"; @@ -305,13 +311,13 @@ public function testOpaqueBootstrapRequiresRuntimeEvidenceAndIsNotExecuted(strin } if ($case === 'hook') { $this->write('early.php', 'write('composer.json', '{"extra":{"sympress-runtime":{"early-hook-file":"early.php"}}}'); + $this->configuration(['early-hook-file' => 'early.php']); } if ($case === 'environment-bootstrap') { $this->write('production.php', 'write('composer.json', '{"extra":{"sympress-runtime":{"wp-config-autoload":true}}}'); + $this->configuration(['wp-config-autoload' => true]); } [$exit, $checks, $output] = $this->diagnose(); self::assertSame(2, $exit, $output); @@ -319,12 +325,13 @@ public function testOpaqueBootstrapRequiresRuntimeEvidenceAndIsNotExecuted(strin self::assertSame('unknown', $checks[$name]); } self::assertFileDoesNotExist($this->root . '/executed'); + self::assertFileDoesNotExist($this->root . '/public/executed'); } public function testCompleteGeneratedConfigurationRemainsStaticallyVerifiable(): void { $this->fixture(); - $this->write('composer.json', '{"extra":{"sympress-runtime":{"require-wp":false}}}'); + $this->configuration(['require-wp' => false]); $process = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/bin/runtime', '-n', 'wpconfig', '--force'], $this->root, ['COMPOSER' => false, 'COMPOSER_VENDOR_DIR' => dirname(__DIR__, 2) . '/vendor']); $process->mustRun(); [$exit, $checks, $output] = $this->diagnose(); diff --git a/tests/Integration/ProductionWpConfigTest.php b/tests/Integration/ProductionWpConfigTest.php index 6680ff1..fec4ba9 100644 --- a/tests/Integration/ProductionWpConfigTest.php +++ b/tests/Integration/ProductionWpConfigTest.php @@ -150,7 +150,8 @@ public function testComposerManagedSectionCanBeRemovedThroughEditor(): void public function testBundledProductionBootCreatesGroupReadableCacheAndWarmBootAvoidsParser(): void { $this->fixture(['composer-managed' => 'auto', 'bundle-bootstrap' => true, 'generated-file-mode' => '0640', 'cache-env' => 'auto']); - foreach (['wp-config.php', 'public/wp-config.php'] as $file) { + self::assertFileDoesNotExist($this->root . '/wp-config.php'); + foreach (['public/wp-config.php'] as $file) { self::assertSame(0640, fileperms($this->root . '/' . $file) & 0777); } $report = <<<'PHP' diff --git a/tests/Integration/ReleaseCandidateRecoveryTest.php b/tests/Integration/ReleaseCandidateRecoveryTest.php index eba4341..454dcaf 100644 --- a/tests/Integration/ReleaseCandidateRecoveryTest.php +++ b/tests/Integration/ReleaseCandidateRecoveryTest.php @@ -78,8 +78,8 @@ public function testPreviousGeneratedConfigurationBootsAfterNewPayloadPublicatio { $this->fixture(); $this->runtime(['wpconfig']); - $oldConfiguration = (string) file_get_contents($this->root . '/wp-config.php'); - $oldProxy = (string) file_get_contents($this->root . '/public/wp-config.php'); + $oldConfiguration = (string) file_get_contents($this->root . '/public/wp-config.php'); + self::assertFileDoesNotExist($this->root . '/wp-config.php'); $before = $this->successfulRequest(); self::assertSame(['stage', 'staging', 'raw-stage'], array_slice($before, 0, 3)); self::assertSame([true, false], array_slice($before, 5)); @@ -93,13 +93,12 @@ public function testPreviousGeneratedConfigurationBootsAfterNewPayloadPublicatio $this->manifest(true, false); $this->runtime(['wpconfig']); self::assertCount(2, glob($this->root . '/var/runtime/*/manifest.json')); - self::assertNotSame($oldConfiguration, file_get_contents($this->root . '/wp-config.php')); + self::assertNotSame($oldConfiguration, file_get_contents($this->root . '/public/wp-config.php')); $updated = $this->successfulRequest(); self::assertNotSame($before[3], $updated[3]); self::assertSame($before[4], $updated[4], 'Regeneration must preserve authentication salts.'); - $this->write('wp-config.php', $oldConfiguration); - $this->write('public/wp-config.php', $oldProxy); + $this->write('public/wp-config.php', $oldConfiguration); self::assertSame($before, $this->successfulRequest()); self::assertSame($oldManifest, file_get_contents($manifests[0])); foreach ($oldFiles as $path => $hash) { diff --git a/tests/Integration/WordPressBootTest.php b/tests/Integration/WordPressBootTest.php index f300c31..5246fb2 100644 --- a/tests/Integration/WordPressBootTest.php +++ b/tests/Integration/WordPressBootTest.php @@ -10,6 +10,7 @@ use RecursiveIteratorIterator; use SymPress\Runtime\Database\DbHost; use SymPress\Runtime\Tests\Support\TemporaryProject; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; use mysqli; @@ -31,24 +32,26 @@ public function testNativeMuPluginsLoadThroughRealWordPressAndAppearInAdminMetad $connection = new mysqli($endpoint->host, $user, $password, null, $endpoint->port, $endpoint->socket); try { $connection->query('CREATE DATABASE `' . $database . '`'); - self::assertTrue(symlink($core, $this->root . '/wp')); - $this->write('composer.json', '{"extra":{"wordpress-install-dir":"wp","wordpress-content-dir":"content","sympress-runtime":{"require-wp":false,"db-check":false,"cache-env":false,"compatibility":false}}}'); - $this->write('content/mu-plugins/z-last/main.php', "write('content/mu-plugins/a-first/main.php', "mirror($core, $this->root . '/public/wp'); + $this->write('composer.json', '{"extra":{"wordpress-install-dir":"public/wp","wordpress-content-dir":"public/content","sympress-runtime":{"require-wp":false,"db-check":false,"cache-env":false,"compatibility":false}}}'); + $this->write('public/content/mu-plugins/z-last/main.php', "write('public/content/mu-plugins/a-first/main.php', "root, ['COMPOSER_VENDOR_DIR' => $package . '/vendor', 'COMPOSER' => false]); $setup->mustRun(); - $loader = $this->root . '/content/mu-plugins/sympress-runtime-mu-loader.php'; + self::assertFileDoesNotExist($this->root . '/wp-config.php'); + self::assertStringNotContainsString('wp-config-proxy:v1', (string) file_get_contents($this->root . '/public/wp-config.php')); + $loader = $this->root . '/public/content/mu-plugins/sympress-runtime-mu-loader.php'; rename($loader, $this->root . '/linked-loader.php'); self::assertTrue(symlink($this->root . '/linked-loader.php', $loader)); $this->write('probe.php', <<<'PHP' */ + public static function configurationLocations(): iterable + { + yield 'native' => ['native', false]; + yield 'release compatibility' => ['release-3.0.1', false]; + yield 'development compatibility' => ['upstream-dev', true]; + } + + #[DataProvider('configurationLocations')] + #[Group('PAR-CFG-012')] + #[Group('PAR-STEP-002')] + public function testDefaultConfigurationLocationPreservesCompatibility(string $profile, bool $proxy): void + { + $this->fixture(['compatibility-profile' => $profile]); + $generated = $this->generate(); + self::assertSame(0, $generated->getExitCode(), $generated->getErrorOutput()); + $public = (string) file_get_contents($this->root . '/public/wp-config.php'); + self::assertSame($proxy, is_file($this->root . '/wp-config.php')); + self::assertStringContainsString($proxy ? 'wp-config-proxy:v1' : 'wp-config:v1', $public); + self::assertSame([1], $this->boot('echo json_encode([$GLOBALS["settings_count"]]);')); + } + + #[Group('PAR-WP-014')] + public function testPreviousManagedRootConfigurationMigratesWithoutExecutingOrRotatingSalts(): void + { + $this->fixture(['compatibility-profile' => 'upstream-dev', 'compatibility' => false]); + self::assertSame(0, $this->generate()->getExitCode()); + $previous = (string) file_get_contents($this->root . '/wp-config.php'); + $keys = $this->boot('echo json_encode([AUTH_KEY, NONCE_SALT]);'); + $this->write('wp-config.php', str_replace("root . '/wp-config.php'); + $this->fixture(['compatibility' => false]); + $generated = $this->generate(); + self::assertSame(0, $generated->getExitCode(), $generated->getErrorOutput()); + self::assertStringContainsString('Migrating managed root configuration', $generated->getOutput()); + self::assertSame($keys, $this->boot('echo json_encode([AUTH_KEY, NONCE_SALT]);')); + self::assertSame($retained, file_get_contents($this->root . '/wp-config.php')); + self::assertStringNotContainsString('wp-config-proxy:v1', (string) file_get_contents($this->root . '/public/wp-config.php')); + } + + public function testPreviousEditedRootConfigurationRequiresExplicitMigrationBeforeWrites(): void + { + $this->fixture(['compatibility-profile' => 'upstream-dev']); + self::assertSame(0, $this->generate()->getExitCode()); + $paths = new Paths($this->root, wp: 'public/wp', content: 'public/content'); + $editor = new WpConfigSectionEditor($paths, new Config([], new Validator($paths), 'upstream-dev'), new Filesystem()); + $editor->append('BEFORE_BOOTSTRAP', 'require __DIR__ . "/custom-bootstrap.php";'); + $previous = file_get_contents($this->root . '/wp-config.php'); + $proxy = file_get_contents($this->root . '/public/wp-config.php'); + $this->fixture(); + $generated = $this->generate(); + self::assertNotSame(0, $generated->getExitCode()); + self::assertStringContainsString('Existing root configuration has edited sections', $generated->getErrorOutput()); + self::assertSame($previous, file_get_contents($this->root . '/wp-config.php')); + self::assertSame($proxy, file_get_contents($this->root . '/public/wp-config.php')); + } + + /** @return iterable */ + public static function locationDependentKeys(): iterable + { + yield 'directory constant' => ['require __DIR__ . "/salt-bootstrap.php";']; + yield 'file constant' => ['require dirname(__FILE__) . "/salt-bootstrap.php";']; + } + + #[DataProvider('locationDependentKeys')] + public function testRelativePathInKeysRequiresExplicitMigrationBeforeWrites(string $code): void + { + $this->fixture(['compatibility-profile' => 'upstream-dev']); + self::assertSame(0, $this->generate()->getExitCode()); + $paths = new Paths($this->root, wp: 'public/wp', content: 'public/content'); + $editor = new WpConfigSectionEditor($paths, new Config([], new Validator($paths), 'upstream-dev'), new Filesystem()); + $editor->append('KEYS', $code); + $previous = file_get_contents($this->root . '/wp-config.php'); + $proxy = file_get_contents($this->root . '/public/wp-config.php'); + $artifacts = scandir($this->root . '/var/runtime'); + $this->fixture(); + $generated = $this->generate(); + self::assertNotSame(0, $generated->getExitCode()); + self::assertStringContainsString('Existing root configuration has location-dependent salt section', $generated->getErrorOutput()); + self::assertSame($previous, file_get_contents($this->root . '/wp-config.php')); + self::assertSame($proxy, file_get_contents($this->root . '/public/wp-config.php')); + self::assertSame($artifacts, scandir($this->root . '/var/runtime')); + } + + public function testEditedLiteralSaltSurvivesMigration(): void + { + $this->fixture(['compatibility-profile' => 'upstream-dev', 'compatibility' => false]); + self::assertSame(0, $this->generate()->getExitCode()); + $keys = $this->boot('echo json_encode([AUTH_KEY, NONCE_SALT]);'); + self::assertIsString($keys[0]); + $previous = (string) file_get_contents($this->root . '/wp-config.php'); + $this->write('wp-config.php', str_replace(var_export($keys[0], true), var_export('synthetic edited literal salt', true), $previous)); + $this->fixture(['compatibility' => false]); + $generated = $this->generate(); + self::assertSame(0, $generated->getExitCode(), $generated->getErrorOutput()); + self::assertSame(['synthetic edited literal salt', $keys[1]], $this->boot('echo json_encode([AUTH_KEY, NONCE_SALT]);')); + } + + /** @return iterable */ + public static function rootSymlinks(): iterable + { + yield 'existing in-project destination' => [false]; + yield 'broken destination' => [true]; + } + + #[DataProvider('rootSymlinks')] + public function testRootSymlinkRequiresExplicitMigrationBeforeWrites(bool $broken): void + { + $this->fixture(['compatibility-profile' => 'upstream-dev']); + self::assertSame(0, $this->generate()->getExitCode()); + self::assertTrue(rename($this->root . '/wp-config.php', $this->root . '/previous-config.php')); + self::assertTrue(symlink($broken ? 'missing-config.php' : 'previous-config.php', $this->root . '/wp-config.php')); + $previous = file_get_contents($this->root . '/previous-config.php'); + $proxy = file_get_contents($this->root . '/public/wp-config.php'); + $artifacts = scandir($this->root . '/var/runtime'); + $this->fixture(); + $generated = $this->generate(); + self::assertNotSame(0, $generated->getExitCode()); + self::assertStringContainsString('Existing root configuration is a symlink', $generated->getErrorOutput()); + self::assertTrue(is_link($this->root . '/wp-config.php')); + self::assertSame($previous, file_get_contents($this->root . '/previous-config.php')); + self::assertSame($proxy, file_get_contents($this->root . '/public/wp-config.php')); + self::assertSame($artifacts, scandir($this->root . '/var/runtime')); + } + + public function testNativeParentTargetCannotOverwriteUnmanagedConfiguration(): void + { + $this->fixture(); + $this->write('public/wp-config.php', 'generate(); + self::assertSame(0, $generated->getExitCode()); + self::assertStringContainsString('Preserved protected target', $generated->getOutput()); + self::assertSame('root . '/public/wp-config.php')); + self::assertFileDoesNotExist($this->root . '/wp-config.php'); + self::assertSame([], glob($this->root . '/var/runtime/*/bootstrap.php')); + } + + /** @return iterable */ + public static function unsafeRootConfigurations(): iterable + { + yield 'unmanaged root' => [false, 'Existing root configuration must be migrated explicitly']; + yield 'dynamic salts' => [true, 'nonliteral salt definition']; + } + + #[DataProvider('unsafeRootConfigurations')] + public function testUnsafeRootMigrationDoesNotReplaceEitherConfiguration(bool $dynamic, string $error): void + { + $this->fixture(['compatibility-profile' => 'upstream-dev']); + self::assertSame(0, $this->generate()->getExitCode()); + if (!$dynamic) { + $this->write('wp-config.php', 'root, wp: 'public/wp', content: 'public/content'); + $editor = new WpConfigSectionEditor($paths, new Config([], new Validator($paths), 'upstream-dev'), new Filesystem()); + $editor->replace('KEYS', 'define("AUTH_KEY", str_repeat("dynamic", 3));'); + } + $previous = file_get_contents($this->root . '/wp-config.php'); + $proxy = file_get_contents($this->root . '/public/wp-config.php'); + $artifacts = scandir($this->root . '/var/runtime'); + $this->fixture(); + $generated = $this->generate(); + self::assertNotSame(0, $generated->getExitCode()); + self::assertStringContainsString($error, $generated->getErrorOutput()); + self::assertSame($previous, file_get_contents($this->root . '/wp-config.php')); + self::assertSame($proxy, file_get_contents($this->root . '/public/wp-config.php')); + self::assertSame($artifacts, scandir($this->root . '/var/runtime')); + } + + /** @param array $settings */ + private function fixture(array $settings = []): void + { + $this->write('composer.json', json_encode(['extra' => ['wordpress-install-dir' => 'public/wp', 'wordpress-content-dir' => 'public/content', 'sympress-runtime' => array_replace(['require-wp' => false, 'db-check' => false, 'cache-env' => false], $settings)]], JSON_THROW_ON_ERROR)); + $this->write('.env', "WP_HOME=http://localhost\n"); + $this->write('public/content/keep', 'content'); + $this->write('public/wp/wp-settings.php', 'write('public/wp/wp-includes/plugin.php', <<<'PHP' + $arguments + * @param array $environment + */ + private function generate(array $arguments = ['wpconfig'], array $environment = []): Process + { + $package = dirname(__DIR__, 2); + $process = new Process([PHP_BINARY, $package . '/bin/runtime', '-n', ...$arguments], $this->root, array_replace(['COMPOSER_VENDOR_DIR' => $package . '/vendor', 'COMPOSER' => false], $environment)); + $process->run(); + + return $process; + } + + /** + * @param array $environment + * @return array + */ + private function boot(string $after, string $before = '', array $environment = []): array + { + $this->write('probe.php', 'root . '/probe.php'], $this->root, array_replace($defaults, $environment)); + $process->run(); + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + + return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Integration/WpConfigTest.php b/tests/Integration/WpConfigTest.php index bb21572..f5b1e94 100644 --- a/tests/Integration/WpConfigTest.php +++ b/tests/Integration/WpConfigTest.php @@ -224,7 +224,7 @@ public function testGeneratedConfigurationBootsWithoutVendorAndPreservesOrdering self::assertFileDoesNotExist($this->root . '/vendor/autoload.php'); $actual = $this->boot('echo json_encode([$GLOBALS["order"], apply_filters("fixture", null), DB_PASSWORD, AUTH_KEY, WP_ENV, WP_ENVIRONMENT_TYPE, WP_DEBUG, $table_prefix, WP_HOME, WP_SITEURL, WP_CONTENT_URL, $GLOBALS["settings_count"], isset($envLoader), isset($debugInfo), class_exists("Composer\\\\Autoload\\\\ClassLoader", false)]);', '$_SERVER["SERVER_NAME"] = "example.test"; $_SERVER["SERVER_PORT"] = 8080'); self::assertSame([[true, true], 'hook-ready', 'synthetic &value', 'environment-key', 'dev-custom', 'development', false, 'custom_', 'http://example.test:8080', 'http://example.test:8080/wp', 'http://example.test:8080/content', 1, false, false, false], $actual); - self::assertSame(0600, fileperms($this->root . '/wp-config.php') & 0777); + self::assertSame(0600, fileperms($this->root . '/public/wp-config.php') & 0777); } #[Group('PAR-SYM-005')] @@ -254,13 +254,13 @@ public function testSeparateGenerationsRetainSaltsSectionEditsAndUnchangedBytes( $this->fixture(); $first = $this->generate(); self::assertSame(0, $first->getExitCode(), $first->getErrorOutput()); - $before = file_get_contents($this->root . '/wp-config.php'); - touch($this->root . '/wp-config.php', 1000000000); + $before = file_get_contents($this->root . '/public/wp-config.php'); + touch($this->root . '/public/wp-config.php', 1000000000); $second = $this->generate(); self::assertSame(0, $second->getExitCode(), $second->getErrorOutput()); - self::assertSame($before, file_get_contents($this->root . '/wp-config.php')); + self::assertSame($before, file_get_contents($this->root . '/public/wp-config.php')); clearstatcache(); - self::assertSame(1000000000, filemtime($this->root . '/wp-config.php')); + self::assertSame(1000000000, filemtime($this->root . '/public/wp-config.php')); $paths = new Paths($this->root, wp: 'public/wp', content: 'public/content'); $editor = new WpConfigSectionEditor($paths, new Config([], new Validator($paths)), new Filesystem()); $editor->append('BEFORE_BOOTSTRAP', 'define("CUSTOM_SECTION_VALUE", "keep");'); @@ -268,9 +268,9 @@ public function testSeparateGenerationsRetainSaltsSectionEditsAndUnchangedBytes( self::assertSame(0, $third->getExitCode(), $third->getErrorOutput()); self::assertStringContainsString('Preserved edited configuration section: BEFORE_BOOTSTRAP', $third->getOutput()); self::assertSame(['keep'], $this->boot('echo json_encode([CUSTOM_SECTION_VALUE]);')); - $edited = file_get_contents($this->root . '/wp-config.php'); + $edited = file_get_contents($this->root . '/public/wp-config.php'); self::assertSame(0, $this->generate()->getExitCode()); - self::assertSame($edited, file_get_contents($this->root . '/wp-config.php')); + self::assertSame($edited, file_get_contents($this->root . '/public/wp-config.php')); } #[Group('PAR-STEP-002')] @@ -278,7 +278,7 @@ public function testSeparateGenerationsRetainSaltsSectionEditsAndUnchangedBytes( #[Group('PAR-TPL-005')] public function testProtectedProxyBlocksAllConfigWritesAndForceKeepsExistingSalts(): void { - $this->fixture(); + $this->fixture(['compatibility-profile' => 'upstream-dev', 'prevent-overwrite' => ['public/wp-config.php']]); $this->write('public/wp-config.php', 'generate(); self::assertNotSame(0, $blocked->getExitCode()); @@ -302,9 +302,9 @@ public function testEditedDynamicSaltSectionIsPreservedAndMissingKeysAreRepaired $regenerated = $this->generate(); self::assertSame(0, $regenerated->getExitCode(), $regenerated->getErrorOutput()); self::assertSame(['dynamicdynamicdynamic', 64, 64], $this->boot('echo json_encode([AUTH_KEY, strlen(NONCE_KEY), strlen(NONCE_SALT)]);')); - $before = file_get_contents($this->root . '/wp-config.php'); + $before = file_get_contents($this->root . '/public/wp-config.php'); self::assertSame(0, $this->generate()->getExitCode()); - self::assertSame($before, file_get_contents($this->root . '/wp-config.php')); + self::assertSame($before, file_get_contents($this->root . '/public/wp-config.php')); } /** @return iterable}> */ @@ -373,7 +373,7 @@ public function testShutdownCacheRestoresInFreshRequestAndActualEnvironmentWins( #[Group('PAR-WP-017')] public function testProxyWpCliGuardSkipsWordPressSettings(): void { - $this->fixture(['compatibility' => false]); + $this->fixture(['compatibility-profile' => 'upstream-dev', 'compatibility' => false]); self::assertSame(0, $this->generate()->getExitCode()); self::assertSame([false, false, false], $this->boot('echo json_encode([isset($GLOBALS["settings_count"]), function_exists("wpstarter_getenv"), defined("WPSTARTER_PATH")]);', 'define("WP_CLI", true)')); } @@ -447,12 +447,12 @@ public function testKeysSectionDoesNotPermitDiscardingDynamicDefinitionsOutsideI { $this->fixture(); $source = "write('wp-config.php', $source); + $this->write('public/wp-config.php', $source); $generated = $this->generate(['wpconfig', '--force']); self::assertNotSame(0, $generated->getExitCode()); self::assertStringContainsString('nonliteral salt definition: AUTH_KEY', $generated->getErrorOutput()); - self::assertSame($source, file_get_contents($this->root . '/wp-config.php')); - self::assertFileDoesNotExist($this->root . '/public/wp-config.php'); + self::assertSame($source, file_get_contents($this->root . '/public/wp-config.php')); + self::assertFileDoesNotExist($this->root . '/wp-config.php'); self::assertSame(['.', '..', '.maintenance.lock'], scandir($this->root . '/var/runtime'), 'A rejected generation may create only its maintenance lock, never salts or runtime payloads.'); } @@ -460,7 +460,7 @@ public function testGeneratedFileContainsEveryInventoriedSection(): void { $this->fixture(); self::assertSame(0, $this->generate()->getExitCode()); - $sections = (new SectionMerger())->sections((string) file_get_contents($this->root . '/wp-config.php')); + $sections = (new SectionMerger())->sections((string) file_get_contents($this->root . '/public/wp-config.php')); $inventory = json_decode((string) file_get_contents(dirname(__DIR__, 2) . '/docs/maintainers/upstream-inventory.json'), true, flags: JSON_THROW_ON_ERROR); $names = array_unique([...array_column($inventory['baselines']['release']['sections'], 'name'), ...array_column($inventory['baselines']['dev']['sections'], 'name')]); $names[] = 'COMPOSER_MANAGED'; diff --git a/tools/consumer-replay.py b/tools/consumer-replay.py index 44b79e5..242dcf3 100644 --- a/tools/consumer-replay.py +++ b/tools/consumer-replay.py @@ -8,10 +8,16 @@ import subprocess +def configuration_paths(extra): + runtime = extra.get("sympress-runtime", {}) + parent = runtime.get("wordpress-parent-dir", Path(extra.get("wordpress-install-dir", "wordpress")).parent.as_posix()) + return ["wp-config.php", (Path(parent) / "wp-config.php").as_posix()] + + def snapshot(project): result = {} extra = json.loads((project / "composer.json").read_text()).get("extra", {}) - targets = ["wp-config.php", "wp-cli.yml", "public/index.php", extra.get("wordpress-content-dir", "wp-content"), "var/runtime", "packages/sympress-demo/assets", "packages/sympress-demo/.sympress_asset_compiler.lock"] + targets = [*configuration_paths(extra), "wp-cli.yml", "public/index.php", extra.get("wordpress-content-dir", "wp-content"), "var/runtime", "packages/sympress-demo/assets", "packages/sympress-demo/.sympress_asset_compiler.lock"] for name in targets: target = project / name paths = [target, *target.rglob("*")] if target.is_dir() and not target.is_symlink() else [target] @@ -36,7 +42,8 @@ def main(): for project in args.projects: project = project.resolve() baseline = snapshot(project) - assert "wp-config.php" in baseline, "Consumer must already be installed." + extra = json.loads((project / "composer.json").read_text()).get("extra", {}) + assert any(name in baseline for name in configuration_paths(extra)), "Consumer must already be installed." report = {"project": project.name, "artifact_count": len(baseline), "runs": []} commands = [ ["ddev", "composer", "install", "--no-interaction", "--no-progress"], diff --git a/tools/smoke-examples.py b/tools/smoke-examples.py index e7ca5af..22bbb5d 100644 --- a/tools/smoke-examples.py +++ b/tools/smoke-examples.py @@ -32,9 +32,13 @@ def run(command): run(binary + ["validate"]) run(binary + ["--list-steps"]) run(binary + ["--no-interaction"]) - for file in ["wp-config.php", "wp-cli.yml", "public/index.php", "public/wp/wp-settings.php"]: + for file in ["public/wp-config.php", "wp-cli.yml", "public/index.php", "public/wp/wp-settings.php"]: if not (project / file).is_file(): raise RuntimeError("Missing documented artifact: " + file) + if (project / "wp-config.php").exists(): + raise RuntimeError("Native example unexpectedly generated a root configuration.") + if "wp-config-proxy:v1" in (project / "public/wp-config.php").read_text(): + raise RuntimeError("Native example configuration is a forwarding loader.") (project / "build-scripts").mkdir() shutil.copyfile(ROOT / "examples/WriteBuildMarker.php", project / "build-scripts/WriteBuildMarker.php") manifest["autoload"] = {"psr-4": {"Example\\Build\\": "build-scripts/"}} diff --git a/tools/tests/SharedGroupTest.php b/tools/tests/SharedGroupTest.php index 3a32ef1..49f3474 100644 --- a/tools/tests/SharedGroupTest.php +++ b/tools/tests/SharedGroupTest.php @@ -67,7 +67,9 @@ public function testSharedGroupCanBootstrapPrivateArtifactsWhileOtherIdentityIsD $payloads = glob($this->root . '/var/runtime/*/bootstrap.php'); self::assertIsArray($payloads); self::assertCount(1, $payloads); - foreach (['wp-config.php', 'public/wp-config.php', '.env.cached.php'] as $file) { + self::assertFileDoesNotExist($this->root . '/wp-config.php'); + self::assertStringNotContainsString('wp-config-proxy:v1', (string) file_get_contents($this->root . '/public/wp-config.php')); + foreach (['public/wp-config.php', '.env.cached.php'] as $file) { self::assertSame(0640, fileperms($this->root . '/' . $file) & 0777); self::assertSame(65534, filegroup($this->root . '/' . $file)); } @@ -76,7 +78,7 @@ public function testSharedGroupCanBootstrapPrivateArtifactsWhileOtherIdentityIsD $identity = (int) $argv[1]; if (!posix_setgid($identity) || !posix_setuid($identity)) { throw new RuntimeException('Cannot switch probe identity.'); } clearstatcache(); -$readable = [is_readable(__DIR__ . '/wp-config.php'), is_readable(__DIR__ . '/.env.cached.php'), is_readable($argv[2])]; +$readable = [is_readable(__DIR__ . '/public/wp-config.php'), is_readable(__DIR__ . '/.env.cached.php'), is_readable($argv[2])]; if ($identity !== 65534) { echo json_encode($readable); return; } require __DIR__ . '/public/wp-config.php'; $debug = apply_filters('debug_information', []); diff --git a/tools/tests/test_consumer_replay.py b/tools/tests/test_consumer_replay.py new file mode 100644 index 0000000..a1a0756 --- /dev/null +++ b/tools/tests/test_consumer_replay.py @@ -0,0 +1,54 @@ +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + + +spec = importlib.util.spec_from_file_location("consumer_replay", Path(__file__).parents[1] / "consumer-replay.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class ConsumerReplayTest(unittest.TestCase): + def test_native_configuration_changes_are_detected_for_configured_layouts(self): + for core, settings, configuration in [ + ("public/wp", {}, "public/wp-config.php"), + ("web/core/wp", {}, "web/core/wp-config.php"), + ("public/wp", {"wordpress-parent-dir": "web"}, "web/wp-config.php"), + ]: + with self.subTest(configuration=configuration), tempfile.TemporaryDirectory() as directory: + project = Path(directory) + extra = {"wordpress-install-dir": core, "sympress-runtime": settings} + (project / "composer.json").write_text(json.dumps({"extra": extra})) + target = project / configuration + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"original configuration") + before = module.snapshot(project) + self.assertTrue(any(name in before for name in module.configuration_paths(extra))) + self.assertEqual(hashlib.sha256(b"original configuration").hexdigest(), before[configuration]) + self.assertNotIn("wp-config.php", before) + target.write_bytes(b"unexpected replacement") + self.assertNotEqual(before[configuration], module.snapshot(project)[configuration]) + + def test_retained_root_and_parent_configurations_are_both_monitored(self): + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) + (project / "composer.json").write_text('{"extra":{"wordpress-install-dir":"public/wp"}}') + (project / "wp-config.php").write_text("retained original") + (project / "public").mkdir() + (project / "public/wp-config.php").write_text("active configuration") + baseline = module.snapshot(project) + self.assertEqual({"wp-config.php", "public/wp-config.php"}, set(baseline)) + for relative in baseline: + with self.subTest(configuration=relative): + target = project / relative + original = target.read_bytes() + target.write_bytes(b"unexpected replacement") + self.assertNotEqual(baseline[relative], module.snapshot(project)[relative]) + target.write_bytes(original) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/wpackagist-smoke.py b/tools/wpackagist-smoke.py index 3e71ca4..225fe04 100644 --- a/tools/wpackagist-smoke.py +++ b/tools/wpackagist-smoke.py @@ -59,7 +59,9 @@ def main(): hooks = (project / "hook-count").read_text() if mode == "no-plugins": run([args.php, str(project / "vendor/bin/runtime"), "--no-interaction"], project) - paths = {"core": "public/wp/wp-settings.php", "plugin": "public/content/plugins/classic-editor/classic-editor.php", "theme": "public/content/themes/twentytwentyfive/style.css", "configuration": "wp-config.php"} + paths = {"core": "public/wp/wp-settings.php", "plugin": "public/content/plugins/classic-editor/classic-editor.php", "theme": "public/content/themes/twentytwentyfive/style.css", "configuration": "public/wp-config.php"} + assert not (project / "wp-config.php").exists(), "Native setup must not generate a root configuration" + assert "wp-config-proxy:v1" not in (project / paths["configuration"]).read_text(), "The native configuration must not be a forwarding loader" hashes = {name: hashlib.sha256((project / path).read_bytes()).hexdigest() for name, path in paths.items()} installed = json.loads((project / "vendor/composer/installed.json").read_text())["packages"] library_package = next(package for package in installed if package['name'] == 'composer/composer')