Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dependency-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ jobs:
permissions:
contents: read
issues: write
uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
uses: sympress/workflows/.github/workflows/dependency-canary.yml@78d083a9a0176cf0252318b0c69fc64938a727d6
with:
php_version: '8.5'
4 changes: 2 additions & 2 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ jobs:
- name: Check documentation and public references
run: python3 tools/check-docs.py
workflows:
uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
uses: sympress/workflows/.github/workflows/lint-workflows.yml@78d083a9a0176cf0252318b0c69fc64938a727d6
qa:
uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
uses: sympress/workflows/.github/workflows/sympress-qa.yml@78d083a9a0176cf0252318b0c69fc64938a727d6
with:
php_version: '8.5'
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- Remove persisted process-value fingerprints; evaluate compiled interpolation templates on every warm request without reparsing Dotenv, including rotated, absent and newly supplied values.
- Add side-effect-free `EnvironmentInspection` for requested raw values, sharing Doctor's configured cache/dump/file precedence and rejecting executable PHP or shell substitutions.
- Require a production `APP_SECRET` of at least 32 bytes in Doctor, refresh diagnostic interpolation and report dynamic dump selectors directly.
- Normalize IPv4-mapped IPv6 proxy peers and networks, refresh mutable OPcache environment data and constrain the tested Composer dependency to the 2.10 minor.

## 1.2.0 — 2026-10-03

- Enable WordPress hardening, disallow unfiltered HTML/uploads and block outbound WordPress HTTP by default in Composer-managed production. Only `api.wordpress.org,downloads.wordpress.org` are admitted until an operator supplies a complete project-specific allowlist.
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"require": {
"php": "^8.5",
"composer-plugin-api": "^2.6",
"composer/composer": "^2.10.3",
"composer/composer": "~2.10.3",
"composer/semver": "^3.4",
"opis/json-schema": "^2.6",
"symfony/console": "^8.1",
Expand Down Expand Up @@ -77,7 +77,7 @@
"extra": {
"class": "SymPress\\Runtime\\Composer\\Plugin",
"branch-alias": {
"dev-main": "1.1.x-dev"
"dev-main": "1.2.x-dev"
},
"kernel": {
"bundle": "SymPress\\Runtime\\Bridge\\Kernel\\RuntimeBundle",
Expand Down
27 changes: 19 additions & 8 deletions docs/adr/0023-env-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ Status: implemented, 2026-09-30.
Native runtime caching defaults to auto, checks parent writability before shutdown
registration, and invalidates on size/mtime changes to all attempted chain paths.
Missing paths are recorded so newly deployed overrides invalidate existing caches.
Interpolated process values use parser-produced substitution templates and salted
dependency fingerprints. Runtime cache hits validate those dependencies and
expand templates without Dotenv parsing or persisted process secret bytes.
Missing and empty dependencies remain distinct; changes refresh the original
affected expressions and their file-owned dependencies. Unrelated values stay cached.
Dotenv retains control of quoting, escaping, defaults and file-chain resolution.
Interpolated values use templates compiled through Dotenv when the artifact is
built. Persisted artifacts contain variable names, literal defaults and random
placeholder markers, never fingerprints or other verifiers derived from process
values. Every request evaluates the templates against its current environment,
without reparsing Dotenv. Missing and empty values, defaults, assignment side
effects, quoting and source-chain precedence remain covered by differential tests.
Unrelated values stay cached. Older artifacts refresh through the original parser;
rebuild caches and dumps to use the compiled warm path and remove old fingerprints.
Deployment dumps preserve safe values and non-secret expressions too. Command
results and transitive results remain outside the value map and run live on hits.
Dynamic environment selectors decline persistence because their source-chain
selection cannot be updated through a partial cache. Doctor never executes
persisted dynamic commands. This supersedes the previous whole-dump refusal for
selection cannot be updated through a partial cache; dump creation reports this
directly. Doctor and the public read-only environment inspection share the same
safe reader, refresh compiled interpolation and reject executable artifact data or
command substitutions. They never execute wp-config.php. This supersedes the previous whole-dump refusal for
external interpolation and whole-cache refusal for command substitution.
Deployment dumps remain explicit immutable artifacts. Legacy runtime cache
semantics remain unchanged.
Expand Down Expand Up @@ -64,3 +68,10 @@ must flush caches in that case. Group ownership is a deployment responsibility.
`ProductionEnvironmentTest` covers an actual unprivileged read-only directory,
permissions, new/changed/deleted overrides, immutable dumps, live file secrets,
required value validation and lazy parsing through the bundled bootstrap.

A PHP 8.5.9 container measurement with 30 variable templates and 1,000 requests
per case recorded median reader costs of 0.45 ms from source and 0.19 ms from a
warm cache, including alternating process-secret rotation. Warm p95 was 0.23 ms;
the rotated case was 0.21 ms. Both warm cases verified every result and left
Dotenv unloaded. These synthetic reader timings exclude WordPress boot and
describe the tested container, not a production latency guarantee.
8 changes: 8 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ Constructing a runner or invoking `run` is internal.
`installed` and `reason` are public, with nullable booleans preserving unknown
state. Obtain it through `dbChecker()->status()`.

`EnvironmentInspection::inspect()` reads only requested raw variable names through
Runtime's configured dump/cache/file precedence without setup or WordPress boot.
It preserves the caller's environment and rejects executable PHP/command substitutions.
See [read-only inspection](deployment.md#read-only-environment-inspection) for the
result structure and explicit file/Composer-manifest overrides.

## Services and narrow handles

| `Services` accessor | Handle and supported operations |
Expand Down Expand Up @@ -196,6 +202,8 @@ SymPress\Runtime\Env\EnvReader::rawValue(string $name): ?string
SymPress\Runtime\Env\EnvReader::read(string $name): string|int|float|bool|null
SymPress\Runtime\Env\EnvReader::readMany(string ...$names): array
SymPress\Runtime\Env\EnvReader::write(string $name, string $value): void
SymPress\Runtime\EnvironmentInspection [public type]
SymPress\Runtime\EnvironmentInspection::inspect(string $projectRoot, array $names, ?string $manifest = NULL, ?string $environmentFile = NULL): array [static]
SymPress\Runtime\Event\LifecycleEvent [public type]
SymPress\Runtime\Event\LifecycleEvent::$result: int [readonly]
SymPress\Runtime\Event\LifecycleEvent::$services: SymPress\Runtime\Services [readonly]
Expand Down
41 changes: 41 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,47 @@ PHP identity before switching traffic. A successful package QA suite does not
replace those site checks. Pin a reviewed tagged Runtime release and keep its exact resolved source reference
in the lockfile.

Provide a randomly generated `APP_SECRET` of at least 32 bytes before production
validation. Doctor reports a missing or short value without printing it. Older
projects should provision a new strong secret once through their private environment
or secret store, then rebuild the dump and kernel cache. Do not regenerate an existing
good secret on each deployment: rotation deliberately invalidates signed cache data
and can affect Symfony features using the application secret.

Set `SYMPRESS_PROJECT_DIR` to a persistent project identity, such as the deployment
base directory, before the kernel and object-cache drop-in boot. Its literal value
seeds WordPress cache namespaces/signing and Symfony cache pool prefixes; keep it
identical across release directories and different between projects. Actual package,
configuration and autoloader discovery continues from the active release. Changing
the identity or secret causes a normal cache refill. Provision a durable private
`APP_CACHE_DIR` accessible to the same CLI and PHP-FPM identity and warm it before
traffic; explicit unsafe cache permissions fail with an actionable diagnostic.

After updating interpolation persistence, regenerate ordinary environment caches
and deployment dumps to remove fingerprints stored by older Runtime versions.
New artifacts contain no verifier derived from a process secret. Compiled variable
templates resolve against current process values on every request without loading
Dotenv; command substitutions still require the live parser during application boot.

## Read-only environment inspection

Applications and security checks can request selected raw values without running
setup, loading WordPress or changing the caller's process/ENV/SERVER state:

```php
$snapshot = \SymPress\Runtime\EnvironmentInspection::inspect(
$activeReleaseDirectory,
['APP_SECRET', 'DB_HOST'],
);
```

The result contains `environment`, the requested `values` (null when absent), and
`source` (`dump`, `cache` or `files`). It honors Runtime configuration and live
process precedence. Generated artifacts are decoded as literal data rather than
executed. Command substitutions are rejected. The optional `manifest` argument
means a Composer configuration manifest; `environmentFile` explicitly selects a
file chain and bypasses cached/dumped artifacts. Treat returned values as secrets.

## Required artifacts

`var/runtime/<fingerprint>/` is required application code. Generated `wp-config.php` references its `bootstrap.php` on every request. **Do not exclude all of `var/` or clear `var/runtime` as a cache cleanup.** Each immutable payload includes its manifest, scoped environment classes, Symfony Dotenv/Process source and license notices.
Expand Down
7 changes: 2 additions & 5 deletions src/Console/Doctor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
use SymPress\Runtime\Config\Config;
use SymPress\Runtime\Database\DbChecker;
use SymPress\Runtime\Database\MysqliProbe;
use SymPress\Runtime\Env\EnvFactory;
use SymPress\Runtime\Env\EnvReader;
use SymPress\Runtime\Env\EnvRequirements;
use SymPress\Runtime\EnvironmentInspection;
use SymPress\Runtime\Filesystem\Paths;
use SymPress\Runtime\Filesystem\ProjectBoundary;
use SymPress\Runtime\Kernel\BootOwnership;
Expand Down Expand Up @@ -64,9 +63,7 @@ public function inspect(bool $production = false, bool $databaseHealth = false,
if (!is_string($profile)) {
throw new RuntimeException('Invalid compatibility profile.');
}
$env = $dump
? EnvReader::buildFromCacheDump($environmentDirectory . '/.env.dump.php', $profile, compatibility: $this->config['compatibility']->is(true), refreshInterpolation: false)
: (new EnvFactory($this->config, $this->paths))->create();
[$env] = EnvironmentInspection::loadReader($this->config, $this->paths);
$kernel = new KernelPaths($env, $this->paths);
$canonical = $kernel->environment();
if ($production || in_array($canonical, ['staging', 'production'], true)) {
Expand Down
4 changes: 4 additions & 0 deletions src/Console/DumpEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public function run(string $environment): int

return 1;
}
if (!$reader->canPersistEnvironment()) {
$this->io->error('Cannot publish an environment dump with a dynamic environment selector; use a literal selector and rebuild.');
return 1;
}
$reader->setupConstants();
$directory = $this->config['env-dir']->unwrapOrFallback($this->paths->root());
if (!is_string($directory)) {
Expand Down
2 changes: 2 additions & 0 deletions src/Console/ProductionChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public function inspect(EnvReader $env, bool $dump, ?string $phpUser = null, ?st
$record('production.debug-display', $this->effectiveBoolean($env, 'WP_DEBUG_DISPLAY', false, $defaults && in_array($canonical, ['staging', 'production'], true), $predefined, $opaque), 'Effective WP_DEBUG_DISPLAY must be false.');
$record('production.debug', $this->effectiveBoolean($env, 'WP_DEBUG', false, $defaults && $canonical === 'production', $predefined, $opaque), 'Effective WP_DEBUG must be false for production.');
$record('production.dump', $dump, 'A readable deployment environment dump is required.');
$secret = $env->rawValue('APP_SECRET');
$record('production.app-secret', is_string($secret) && strlen($secret) >= 32, 'APP_SECRET requires at least 32 bytes generated with a cryptographically secure random source.');
$record('production.env-cache', !$this->config['cache-env']->is(false), 'Production must not explicitly disable environment caching.');
$record('production.proxy-trust', $this->hasProxyTrust($env), 'A forwarded scheme header or enabled forwarded SSL requires explicit trusted proxy addresses or CIDRs.');
$record('production.file-mods', $this->effectiveBoolean($env, 'DISALLOW_FILE_MODS', true, $managedDefaults, $predefined, $opaque), 'Effective DISALLOW_FILE_MODS must be true, explicitly or through the generated Composer-managed defaults.');
Expand Down
17 changes: 17 additions & 0 deletions src/Env/EnvCacheExpressions.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace SymPress\Runtime\Env;

use Symfony\Component\Dotenv\Dotenv;

/** Keeps original expressions, never their process-derived results. @internal */
final class EnvCacheExpressions
{
Expand Down Expand Up @@ -52,6 +54,21 @@ public function referencesFor(string $path): array
return array_values(array_unique(array_merge(...array_column($this->sources[$path] ?? [], 'references'))));
}

/** @return array<string, mixed>|null */
public function templates(Dotenv $dotenv): ?array
{
$expressions = $this->payload()['expressions'];
$plans = [];
foreach ($expressions as $path => $content) {
$compiled = EnvCacheTemplates::compile($dotenv, $this->statements($content));
if ($compiled === null) {
return null;
}
$plans[$path] = $compiled;
}
return $plans;
}

public function hasDynamicEnvironmentSelection(): bool
{
foreach ($this->sources as $definitions) {
Expand Down
14 changes: 12 additions & 2 deletions src/Env/EnvCacheFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ final class EnvCacheFormat
private const string HEADER = "<?php\n// @generated by sympress/runtime env-cache:v1\n";

/** @return array<array-key, mixed> */
public static function read(string $file): array
public static function read(string $file, bool $dataOnly = false): array
{
$header = file_get_contents($file, false, null, 0, strlen(self::HEADER));
if ($header !== self::HEADER) {
throw new RuntimeException('Environment cache format is not supported.');
}
try {
$data = (static fn (string $path): mixed => require $path)($file);
if ($dataOnly) {
$source = file_get_contents($file);
if (!is_string($source)) {
throw new RuntimeException('Cannot read environment cache.');
}
$data = (new EnvCacheLiteral())->decode($source);
}
if (!$dataOnly && function_exists('opcache_invalidate')) {
opcache_invalidate($file, true);
}
$data = $dataOnly ? $data : (static fn (string $path): mixed => require $path)($file);
} catch (Throwable) {
throw new RuntimeException('Cannot read environment cache.');
}
Expand Down
Loading
Loading