From 6f4b5baf6c438deada5dfb32cabc7efa9d3fa75b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:03:52 +0000 Subject: [PATCH 1/5] Share the canonical credential fixture loader and pin consumer intent --- scripts/Support/StaticCredentialFixture.php | 106 ++++++++++++++++++ scripts/static-credentials.md | 50 +++++++++ tests/StaticCredentialFixtureTest.php | 82 ++++++++++++++ tests/StaticCredentialValidationTest.php | 55 +++------ .../static-credential-consumer/composer.json | 31 +++++ 5 files changed, 283 insertions(+), 41 deletions(-) create mode 100644 scripts/Support/StaticCredentialFixture.php create mode 100644 scripts/static-credentials.md create mode 100644 tests/StaticCredentialFixtureTest.php create mode 100644 tests/fixtures/static-credential-consumer/composer.json diff --git a/scripts/Support/StaticCredentialFixture.php b/scripts/Support/StaticCredentialFixture.php new file mode 100644 index 00000000..e976725c --- /dev/null +++ b/scripts/Support/StaticCredentialFixture.php @@ -0,0 +1,106 @@ + */ + public readonly array $data; + + public function __construct(string $path) + { + if (!is_file($path) || !is_readable($path) || filesize($path) !== self::BYTES + || hash_file('sha256', $path) !== self::SHA256) { + throw new InvalidArgumentException('The canonical static credential fixture identity differs.'); + } + $data = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new InvalidArgumentException('The canonical static credential fixture must be an object.'); + } + $this->data = $data; + $ids = $this->ids(); + if (count($ids) !== self::IDS || count(array_unique($ids)) !== self::IDS) { + throw new InvalidArgumentException('The canonical static credential fixture inventory differs.'); + } + } + + /** @return list */ + public function ids(): array + { + $ids = [$this->data['binary_key_boundary']['id']]; + foreach (['common_scalar_cases', 'php_byte_loader_cases', 'pre_selection_cases', + 'php_adapter_cases', 'server_pre_selection_cases'] as $group) { + array_push($ids, ...array_column($this->data[$group], 'id')); + } + return $ids; + } + + /** @return array */ + public function scalar(string $id): array + { + return $this->find(['common_scalar_cases', 'php_byte_loader_cases'], $id); + } + + /** @return array */ + public function adapter(string $id): array + { + return $this->find(['pre_selection_cases', 'php_adapter_cases'], $id); + } + + public function value(string $id): string + { + $case = $this->scalar($id); + if (isset($case['input_hex'])) { + $bytes = hex2bin($case['input_hex']); + if ($bytes === false) { + throw new InvalidArgumentException('The canonical byte fixture must contain hex.'); + } + return $bytes; + } + return self::points($case['input_code_points']); + } + + /** @param list $points */ + public static function points(array $points): string + { + $value = ''; + foreach ($points as $point) { + if (!is_int($point) || $point < 0 || $point > 0x10ffff || ($point >= 0xd800 && $point <= 0xdfff)) { + throw new InvalidArgumentException('The fixture must contain Unicode scalar values.'); + } + $value .= match (true) { + $point <= 0x7f => chr($point), + $point <= 0x7ff => chr(0xc0 | ($point >> 6)).chr(0x80 | ($point & 0x3f)), + $point <= 0xffff => chr(0xe0 | ($point >> 12)).chr(0x80 | (($point >> 6) & 0x3f)).chr(0x80 | ($point & 0x3f)), + default => chr(0xf0 | ($point >> 18)).chr(0x80 | (($point >> 12) & 0x3f)) + .chr(0x80 | (($point >> 6) & 0x3f)).chr(0x80 | ($point & 0x3f)), + }; + } + return $value; + } + + /** + * @param list $groups + * @return array + */ + private function find(array $groups, string $id): array + { + foreach ($groups as $group) { + foreach ($this->data[$group] as $case) { + if ($case['id'] === $id) { + return $case; + } + } + } + throw new InvalidArgumentException('The canonical credential case must exist in the selected family.'); + } +} diff --git a/scripts/static-credentials.md b/scripts/static-credentials.md new file mode 100644 index 00000000..af6830ec --- /dev/null +++ b/scripts/static-credentials.md @@ -0,0 +1,50 @@ +# Static credential consumer fixture runner + +This source work prepares the caller-owned check proposed in +[SDK #307](https://github.com/durable-workflow/sdk-php/issues/307#issuecomment-6667259865). +It has not solved or installed its dependency intent and is not an installed +package, HTTP authentication or Server result. + +The shared `Support/StaticCredentialFixture.php` loader is also used by the +existing source regressions. It verifies the canonical fixture's complete +60,716 bytes, SHA-256 and 319 unique IDs before decoding its PHP inputs. +The fixture remains at `tests/fixtures/auth-configuration/static-credentials-v1.json`. +The source controls independently freeze all 296 scalar/byte original values, +including malformed UTF-8, with a length-delimited inventory computed using +Python's Unicode encoder and the original hex inputs. Server-only cases and +the binary-key boundary remain inventory entries, not PHP scalar passes. + +`tests/fixtures/static-credential-consumer/composer.json` is the proposed +dependency intent. Its exact interpreter/package versions and known framework +source references are inputs to a later complete advisory-enforced graph; +there is no lockfile, installed subject or registry SDK substitution here. +Do not resolve it or install packages as a side effect of source controls. + +The caller-owned runner is being authored to cover direct public credential +construction, both service configuration paths, Laravel's process resolver and +real provider/lazy-client/worker-factory paths, and Symfony's real bundle/client/ +worker-factory paths. It will compare the fixture's original bytes and exact +setting-only refusals with a refusing transport spy and no worker loop. It will +not run a runtime smoke suite, acquire tools or manage a runtime/controller. + +The offline loader/regression controls use an existing source vendor: + +```sh +vendor/bin/phpunit tests/StaticCredentialFixtureTest.php tests/StaticCredentialValidationTest.php +``` + +For eventual installed execution, the caller must separately freeze the runner +and its data, obtain the normal published SDK identity, close the dependency +graph and seal all archives before package execution. Copy only the selected +runner/support/data files into the caller-owned packet; do not mount SDK source +or a source-vendor tree as the consumer. Actual Composer package references and +reflected implementation paths must bind the installed package. Synthetic +credential values remain internal to comparisons; only case/target/role, +verdict and setting names may be reported. No real credential or endpoint is +needed or selected by this source work. + +The final combined-minor publication gates on #293 remain independent: +published Workflow support authority, published native flat-child-all and the +normally installed SDK tuple, and final combined candidate qualification still +precede freeze/tag. #219 remains excluded; the future major interface change +is separate. diff --git a/tests/StaticCredentialFixtureTest.php b/tests/StaticCredentialFixtureTest.php new file mode 100644 index 00000000..780510e3 --- /dev/null +++ b/tests/StaticCredentialFixtureTest.php @@ -0,0 +1,82 @@ +ids()); + self::assertCount(319, array_unique($fixture->ids())); + $inventory = hash_init('sha256'); + $count = 0; + foreach (['common_scalar_cases', 'php_byte_loader_cases'] as $group) { + foreach ($fixture->data[$group] as $case) { + $value = $fixture->value($case['id']); + if (isset($case['input_hex']) || isset($case['expected_utf8_hex'])) { + self::assertSame($case['input_hex'] ?? $case['expected_utf8_hex'], bin2hex($value), $case['id']); + } + hash_update($inventory, pack('N', strlen($case['id'])).$case['id'].pack('N', strlen($value)).$value); + ++$count; + } + } + // Independently frozen with Python chr()/UTF-8 and the fixture's original input_hex bytes. + self::assertSame(296, $count); + self::assertSame('b7b61969bea23a2dfad8a85e637e3c592b3dad6f62047a39a04874227e18d0ed', hash_final($inventory)); + self::assertNotSame($fixture->value('scalar-composed'), $fixture->value('scalar-decomposed')); + self::assertSame('', $fixture->value('scalar-literal-empty')); + } + + public function testAChangedFixtureCannotSupplyExpectations(): void + { + $path = tempnam(sys_get_temp_dir(), 'sdk-php-credential-fixture-'); + self::assertIsString($path); + try { + $bytes = (string) file_get_contents(self::path()); + self::assertSame(strlen($bytes), file_put_contents($path, str_replace('scalar-ordinary', 'scalar-mutation', $bytes))); + $this->expectException(InvalidArgumentException::class); + new StaticCredentialFixture($path); + } finally { + unlink($path); + } + } + + public function testServerOnlyRowsCannotBecomePhpScalarCases(): void + { + $fixture = new StaticCredentialFixture(self::path()); + $this->expectException(InvalidArgumentException::class); + $fixture->scalar('server-unused-role'); + } + + /** @param list $points */ + #[DataProvider('nonScalarPoints')] + public function testNonScalarPointsRefuse(array $points): void + { + $this->expectException(InvalidArgumentException::class); + StaticCredentialFixture::points($points); + } + + /** @return iterable}> */ + public static function nonScalarPoints(): iterable + { + yield 'negative' => [[-1]]; + yield 'surrogate' => [[0xd800]]; + yield 'above limit' => [[0x110000]]; + yield 'string' => [['32']]; + } + + private static function path(): string + { + return __DIR__.'/fixtures/auth-configuration/static-credentials-v1.json'; + } +} diff --git a/tests/StaticCredentialValidationTest.php b/tests/StaticCredentialValidationTest.php index 19fde212..8f60740e 100644 --- a/tests/StaticCredentialValidationTest.php +++ b/tests/StaticCredentialValidationTest.php @@ -9,6 +9,7 @@ use DurableWorkflow\Bridge\Laravel\ProcessCredentialResolver; use DurableWorkflow\Bridge\ServiceConfiguration; use DurableWorkflow\Client; +use DurableWorkflow\Qualification\StaticCredentialFixture; use DurableWorkflow\Tests\Support\FakeTransport; use InvalidArgumentException; use LogicException; @@ -16,6 +17,8 @@ use PHPUnit\Framework\TestCase; use ReflectionProperty; +require_once dirname(__DIR__).'/scripts/Support/StaticCredentialFixture.php'; + final class StaticCredentialValidationTest extends TestCase { private const TARGETS = ['Client', 'TokenAuthentication', 'ServiceConfiguration', 'ProcessCredentialResolver']; @@ -321,16 +324,16 @@ private static function setting(string $target, string $role): string }; } - /** @return array */ - private static function fixture(): array + private static function canonical(): StaticCredentialFixture { static $fixture = null; - $fixture ??= json_decode((string) file_get_contents(self::fixturePath()), true, flags: JSON_THROW_ON_ERROR); - if (!is_array($fixture)) { - throw new LogicException('The frozen credential fixture must be an object.'); - } + return $fixture ??= new StaticCredentialFixture(self::fixturePath()); + } - return $fixture; + /** @return array */ + private static function fixture(): array + { + return self::canonical()->data; } private static function fixturePath(): string @@ -341,53 +344,23 @@ private static function fixturePath(): string /** @return array */ private static function case(string $id): array { - foreach (['common_scalar_cases', 'php_byte_loader_cases'] as $group) { - foreach (self::fixture()[$group] as $case) { - if ($case['id'] === $id) { - return $case; - } - } - } - throw new LogicException('The canonical scalar reference must exist.'); + return self::canonical()->scalar($id); } /** @return array */ private static function adapterCase(string $id): array { - foreach (['pre_selection_cases', 'php_adapter_cases'] as $group) { - foreach (self::fixture()[$group] as $case) { - if ($case['id'] === $id) { - return $case; - } - } - } - throw new LogicException('The canonical adapter reference must exist.'); + return self::canonical()->adapter($id); } private static function value(string $id): string { - $case = self::case($id); - if (isset($case['input_hex'])) { - $bytes = hex2bin($case['input_hex']); - if ($bytes === false) { - throw new LogicException('The canonical byte fixture must contain hex.'); - } - - return $bytes; - } - - return self::fromPoints($case['input_code_points']); + return self::canonical()->value($id); } /** @param list $points */ private static function fromPoints(array $points): string { - return implode('', array_map(static fn (int $point): string => match (true) { - $point <= 0x7f => chr($point), - $point <= 0x7ff => chr(0xc0 | ($point >> 6)).chr(0x80 | ($point & 0x3f)), - $point <= 0xffff => chr(0xe0 | ($point >> 12)).chr(0x80 | (($point >> 6) & 0x3f)).chr(0x80 | ($point & 0x3f)), - default => chr(0xf0 | ($point >> 18)).chr(0x80 | (($point >> 12) & 0x3f)) - .chr(0x80 | (($point >> 6) & 0x3f)).chr(0x80 | ($point & 0x3f)), - }, $points)); + return StaticCredentialFixture::points($points); } } diff --git a/tests/fixtures/static-credential-consumer/composer.json b/tests/fixtures/static-credential-consumer/composer.json new file mode 100644 index 00000000..12faf020 --- /dev/null +++ b/tests/fixtures/static-credential-consumer/composer.json @@ -0,0 +1,31 @@ +{ + "name": "durable-workflow/static-credential-consumer", + "description": "Unexecuted dependency intent for the caller-owned static credential fixture runner", + "type": "project", + "require": { + "php": "8.4.27", + "durable-workflow/sdk": "3.10.0", + "laravel/framework": "12.69.3", + "symfony/framework-bundle": "7.4.21" + }, + "config": { + "allow-plugins": false, + "sort-packages": true + }, + "extra": { + "qualification-inputs": { + "fixture_sha256": "0d4f01b763d72850943acbd9a74a0cc5da1ca6db5bc1b6fa5ca37fda11810acb", + "framework_source_references": { + "laravel/framework": "58ea544a2a80dc03c168e13a5dc9a1d176a88717", + "symfony/framework-bundle": "62b38e9cb02c944e01030898b3d2b4f31722e47f" + }, + "future_inputs": [ + "normal_registry_sdk_identity", + "complete_advisory_enforced_dependency_graph", + "sealed_archive_inventory" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} From 195745bc37c26294ce38e88e8de1e2313c2eeedb Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:16:39 +0000 Subject: [PATCH 2/5] Share credential construction probes and refuse fixture transport --- scripts/Support/StaticCredentialProbe.php | 141 ++++++++++++++++++ .../Support/StaticCredentialProbeFailure.php | 12 ++ .../Support/StaticCredentialTransportSpy.php | 63 ++++++++ scripts/static-credentials.md | 13 +- tests/StaticCredentialProbeTest.php | 111 ++++++++++++++ tests/StaticCredentialValidationTest.php | 54 +------ .../static-credential-consumer/composer.json | 31 ---- .../dependency-intent.json | 33 ++++ 8 files changed, 375 insertions(+), 83 deletions(-) create mode 100644 scripts/Support/StaticCredentialProbe.php create mode 100644 scripts/Support/StaticCredentialProbeFailure.php create mode 100644 scripts/Support/StaticCredentialTransportSpy.php create mode 100644 tests/StaticCredentialProbeTest.php delete mode 100644 tests/fixtures/static-credential-consumer/composer.json create mode 100644 tests/fixtures/static-credential-consumer/dependency-intent.json diff --git a/scripts/Support/StaticCredentialProbe.php b/scripts/Support/StaticCredentialProbe.php new file mode 100644 index 00000000..51d52018 --- /dev/null +++ b/scripts/Support/StaticCredentialProbe.php @@ -0,0 +1,141 @@ + 'DURABLE_WORKFLOW_TOKEN', 'control' => 'DURABLE_WORKFLOW_CLIENT_TOKEN', + 'worker' => 'DURABLE_WORKFLOW_WORKER_TOKEN']; + private const CONFIGURATION = ['shared' => 'token', 'control' => 'control_token', 'worker' => 'worker_token']; + + /** + * @param array $options + * @param array $process + * @return array + */ + public static function headers(string $target, array $options, string $plane, Transport $transport, array $process = []): array + { + if (!in_array($plane, ['control', 'worker'], true) + || array_diff(array_keys($options), ['shared', 'control', 'worker']) !== []) { + throw new StaticCredentialProbeFailure('Unsupported credential fixture role.'); + } + if ($target === 'TokenAuthentication') { + $authentication = new TokenAuthentication(token: $options['shared'] ?? null, + controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null); + } else { + $client = match ($target) { + 'Client' => new Client('https://fixture.invalid', transport: $transport, + token: $options['shared'] ?? null, controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null), + 'ServiceConfiguration', 'ServiceConfigurationConstructor', 'ServiceConfigurationAlias' => + self::configured($target, $options, $plane, $transport), + 'ProcessCredentialResolver' => self::process($options, $process, $plane, $transport), + default => throw new StaticCredentialProbeFailure('Unsupported credential target.'), + }; + $authentication = self::authentication($client); + } + return $authentication?->headers($plane === 'worker') ?? []; + } + + public static function authentication(Client $client): ?Authentication + { + $authentication = (new ReflectionProperty(Client::class, 'authentication'))->getValue($client); + if ($authentication !== null && !$authentication instanceof Authentication) { + throw new StaticCredentialProbeFailure('Unsupported constructed authentication.'); + } + return $authentication; + } + + /** + * @template T + * @param array $values + * @param callable(): T $build + * @return T + */ + public static function environment(array $values, callable $build): mixed + { + $names = [...array_values(self::ENVIRONMENT), 'DURABLE_WORKFLOW_PROCESS_ROLE', 'DURABLE_WORKFLOW_PROCESS_TOKEN']; + if (array_diff(array_keys($values), $names) !== []) { + throw new StaticCredentialProbeFailure('Unsupported fixture environment setting.'); + } + $previous = []; + foreach ($names as $name) { + $previous[$name] = getenv($name); + } + try { + foreach ($names as $name) { + self::put($name); + } + foreach ($values as $name => $value) { + self::put($name.'='.$value); + if (getenv($name) !== $value) { + throw new StaticCredentialProbeFailure('Fixture environment bytes differ.'); + } + } + return $build(); + } finally { + foreach ($previous as $name => $value) { + self::put($value === false ? $name : $name.'='.$value); + } + } + } + + /** @param array $options */ + private static function configured(string $target, array $options, string $plane, Transport $transport): Client + { + if ($target === 'ServiceConfigurationConstructor') { + $configuration = new ServiceConfiguration('https://fixture.invalid', 'fixture', 'fixture', [], + $options['shared'] ?? null, $options['control'] ?? null, $options['worker'] ?? null); + } else { + $credentials = []; + foreach ($options as $role => $value) { + $credentials[self::CONFIGURATION[$role]] = $value; + } + $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', + 'namespace' => 'fixture', 'task_queue' => 'fixture', 'handlers' => [], 'credentials' => $credentials]); + } + if ($target === 'ServiceConfigurationAlias') { + if ($plane !== 'control') { + throw new StaticCredentialProbeFailure('The configuration alias is a control client.'); + } + return $configuration->client($transport); + } + return $plane === 'worker' ? $configuration->workerClient($transport) : $configuration->controlClient($transport); + } + + /** + * @param array $options + * @param array $values + */ + private static function process(array $options, array $values, string $plane, Transport $transport): Client + { + foreach ($options as $role => $value) { + if ($value !== null) { + $values[self::ENVIRONMENT[$role]] = $value; + } + } + $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', 'task_queue' => 'fixture']); + return self::environment($values, static fn (): Client => $plane === 'worker' + ? ProcessCredentialResolver::workerClient($configuration, $transport) + : ProcessCredentialResolver::applicationClient($configuration, $transport)); + } + + private static function put(string $setting): void + { + if (!putenv($setting)) { + throw new StaticCredentialProbeFailure('Fixture environment update failed.'); + } + } +} diff --git a/scripts/Support/StaticCredentialProbeFailure.php b/scripts/Support/StaticCredentialProbeFailure.php new file mode 100644 index 00000000..f4db4fcf --- /dev/null +++ b/scripts/Support/StaticCredentialProbeFailure.php @@ -0,0 +1,12 @@ +refuse(); + } + + /** @param array $options */ + public function __invoke(RequestInterface $request, array $options): PromiseInterface + { + $this->refuse(); + } + + /** Instrument actual framework-created clients without replacing their authentication or factories. */ + public function attach(Client $client): void + { + $transport = (new ReflectionProperty(Client::class, 'transport'))->getValue($client); + if ($transport === $this) { + return; + } + if (!$transport instanceof Psr18Transport) { + throw new StaticCredentialProbeFailure('Unsupported framework transport.'); + } + $http = (new ReflectionProperty(Psr18Transport::class, 'client'))->getValue($transport); + if (!$http instanceof GuzzleClient) { + throw new StaticCredentialProbeFailure('Unsupported framework HTTP client.'); + } + $handler = $http->getConfig('handler'); + if (!$handler instanceof HandlerStack) { + throw new StaticCredentialProbeFailure('Unsupported framework HTTP handler.'); + } + $handler->setHandler($this); + // Bounded control requests use a distinct, lazily-created Guzzle client. + (new ReflectionProperty(Psr18Transport::class, 'nativeControlClient'))->setValue($transport, + new GuzzleClient(['http_errors' => false, 'handler' => HandlerStack::create($this)])); + } + + private function refuse(): never + { + ++$this->attempts; + throw new StaticCredentialProbeFailure('A credential fixture attempted transport.'); + } +} diff --git a/scripts/static-credentials.md b/scripts/static-credentials.md index af6830ec..2578e110 100644 --- a/scripts/static-credentials.md +++ b/scripts/static-credentials.md @@ -14,11 +14,22 @@ including malformed UTF-8, with a length-delimited inventory computed using Python's Unicode encoder and the original hex inputs. Server-only cases and the binary-key boundary remain inventory entries, not PHP scalar passes. -`tests/fixtures/static-credential-consumer/composer.json` is the proposed +`tests/fixtures/static-credential-consumer/dependency-intent.json` is the proposed dependency intent. Its exact interpreter/package versions and known framework source references are inputs to a later complete advisory-enforced graph; there is no lockfile, installed subject or registry SDK substitution here. Do not resolve it or install packages as a side effect of source controls. +It is deliberately an intent document rather than an installable Composer +project with a fabricated or unresolved lockfile. + +`StaticCredentialProbe` shares the public construction/header inspection paths +with source regressions. `StaticCredentialTransportSpy` refuses direct SDK +sends. For real framework-created default clients, it instruments the existing +Guzzle handler and separate bounded-control handler without replacing the +provider/factory or selected authentication. Countercontrols exercise both +control and worker requests and require the spy to intercept them. Constructors +remain inside the caller's network-isolated boundary; the fixture runner never +starts a worker loop or transmits credential headers. The caller-owned runner is being authored to cover direct public credential construction, both service configuration paths, Laravel's process resolver and diff --git a/tests/StaticCredentialProbeTest.php b/tests/StaticCredentialProbeTest.php new file mode 100644 index 00000000..2dd9a33e --- /dev/null +++ b/tests/StaticCredentialProbeTest.php @@ -0,0 +1,111 @@ +attach($client); + self::assertSame($authentication, StaticCredentialProbe::authentication($client)); + self::assertSame(0, $spy->attempts); + $refused = false; + try { + if ($worker) { + $client->registerWorker('fixture', 'fixture', [], []); + } else { + $client->clusterInfo(); + } + } catch (Throwable) { + $refused = true; + } + self::assertTrue($refused); + self::assertSame(1, $spy->attempts, 'The real default HTTP path must reach the refusing spy.'); + } + + /** @return iterable */ + public static function requestRoles(): iterable + { + yield 'control' => [false]; + yield 'worker' => [true]; + } + + public function testUnsupportedTransportCannotPassAsInstrumented(): void + { + $client = new Client('https://fixture.invalid', transport: new FakeTransport([])); + $this->expectException(StaticCredentialProbeFailure::class); + (new StaticCredentialTransportSpy())->attach($client); + } + + public function testEnvironmentIsRestoredWhenConstructionRefuses(): void + { + $name = 'DURABLE_WORKFLOW_TOKEN'; + $previous = getenv($name); + self::assertTrue(putenv($name.'=fixture-outside')); + try { + $this->expectException(StaticCredentialProbeFailure::class); + StaticCredentialProbe::environment([$name => "fixture\xff"], static function () use ($name): void { + self::assertSame("fixture\xff", getenv($name)); + throw new StaticCredentialProbeFailure('Fixture refusal.'); + }); + } finally { + self::assertSame('fixture-outside', getenv($name)); + self::assertTrue(putenv($previous === false ? $name : $name.'='.$previous)); + } + } + + public function testUnrelatedEnvironmentNamesAreRefused(): void + { + $this->expectException(StaticCredentialProbeFailure::class); + StaticCredentialProbe::environment(['NOT_A_CREDENTIAL_SETTING' => 'fixture'], static fn (): bool => true); + } + + #[DataProvider('configurationTargets')] + public function testConstructionPathsPreserveOriginalBytesAndRefuseUnusedText(string $target): void + { + $spy = new StaticCredentialTransportSpy(); + $value = "fixture-e\u{0301}"; + self::assertSame(['Authorization' => 'Bearer '.$value], + StaticCredentialProbe::headers($target, ['control' => $value], 'control', $spy)); + try { + StaticCredentialProbe::headers($target, ['control' => $value, 'worker' => "unused\xff"], 'control', $spy); + self::fail('The unused original text must refuse.'); + } catch (InvalidArgumentException $error) { + self::assertSame('Invalid static credential setting credentials.worker_token.', $error->getMessage()); + } + self::assertSame(0, $spy->attempts); + } + + /** @return iterable */ + public static function configurationTargets(): iterable + { + foreach (['ServiceConfiguration', 'ServiceConfigurationConstructor', 'ServiceConfigurationAlias'] as $target) { + yield $target => [$target]; + } + } + + public function testUnknownPlanesDoNotFallBackToControl(): void + { + $this->expectException(StaticCredentialProbeFailure::class); + StaticCredentialProbe::headers('Client', [], 'unsupported', new StaticCredentialTransportSpy()); + } +} diff --git a/tests/StaticCredentialValidationTest.php b/tests/StaticCredentialValidationTest.php index 8f60740e..5f378318 100644 --- a/tests/StaticCredentialValidationTest.php +++ b/tests/StaticCredentialValidationTest.php @@ -7,9 +7,9 @@ use DurableWorkflow\Auth\Authentication; use DurableWorkflow\Auth\TokenAuthentication; use DurableWorkflow\Bridge\Laravel\ProcessCredentialResolver; -use DurableWorkflow\Bridge\ServiceConfiguration; use DurableWorkflow\Client; use DurableWorkflow\Qualification\StaticCredentialFixture; +use DurableWorkflow\Qualification\StaticCredentialProbe; use DurableWorkflow\Tests\Support\FakeTransport; use InvalidArgumentException; use LogicException; @@ -18,6 +18,7 @@ use ReflectionProperty; require_once dirname(__DIR__).'/scripts/Support/StaticCredentialFixture.php'; +require_once dirname(__DIR__).'/scripts/Support/StaticCredentialProbe.php'; final class StaticCredentialValidationTest extends TestCase { @@ -228,56 +229,7 @@ public static function processRoleCases(): iterable */ private static function exercise(string $target, array $options, string $plane, FakeTransport $transport, array $process = []): array { - if ($target === 'TokenAuthentication') { - $authentication = new TokenAuthentication( - token: $options['shared'] ?? null, controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null, - ); - } else { - $client = match ($target) { - 'Client' => new Client('https://server.example', transport: $transport, - token: $options['shared'] ?? null, controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null), - 'ServiceConfiguration' => self::configuredClient($options, $plane, $transport), - 'ProcessCredentialResolver' => self::processClient($options, $process, $plane, $transport), - default => throw new LogicException('The canonical credential target must be supported.'), - }; - // Inspect the constructed auth object without issuing a request or - // claiming arbitrary-Unicode HTTP-header/authentication success. - $authentication = (new ReflectionProperty(Client::class, 'authentication'))->getValue($client); - self::assertTrue($authentication === null || $authentication instanceof Authentication); - } - - return $authentication?->headers($plane === 'worker') ?? []; - } - - /** @param array $options */ - private static function configuredClient(array $options, string $plane, FakeTransport $transport): Client - { - $credentials = []; - foreach ($options as $role => $value) { - $credentials[self::CONFIGURATION[$role]] = $value; - } - $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://server.example', - 'namespace' => 'fixture', 'task_queue' => 'fixture', 'handlers' => [], 'credentials' => $credentials]); - - return $plane === 'worker' ? $configuration->workerClient($transport) : $configuration->controlClient($transport); - } - - /** - * @param array $options - * @param array $process - */ - private static function processClient(array $options, array $process, string $plane, FakeTransport $transport): Client - { - foreach ($options as $role => $value) { - if ($value !== null) { - $process[self::ENVIRONMENT[$role]] = $value; - } - } - $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://server.example', 'task_queue' => 'fixture']); - - return self::withEnvironment($process, static fn (): Client => $plane === 'worker' - ? ProcessCredentialResolver::workerClient($configuration, $transport) - : ProcessCredentialResolver::applicationClient($configuration, $transport)); + return StaticCredentialProbe::headers($target, $options, $plane, $transport, $process); } /** diff --git a/tests/fixtures/static-credential-consumer/composer.json b/tests/fixtures/static-credential-consumer/composer.json deleted file mode 100644 index 12faf020..00000000 --- a/tests/fixtures/static-credential-consumer/composer.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "durable-workflow/static-credential-consumer", - "description": "Unexecuted dependency intent for the caller-owned static credential fixture runner", - "type": "project", - "require": { - "php": "8.4.27", - "durable-workflow/sdk": "3.10.0", - "laravel/framework": "12.69.3", - "symfony/framework-bundle": "7.4.21" - }, - "config": { - "allow-plugins": false, - "sort-packages": true - }, - "extra": { - "qualification-inputs": { - "fixture_sha256": "0d4f01b763d72850943acbd9a74a0cc5da1ca6db5bc1b6fa5ca37fda11810acb", - "framework_source_references": { - "laravel/framework": "58ea544a2a80dc03c168e13a5dc9a1d176a88717", - "symfony/framework-bundle": "62b38e9cb02c944e01030898b3d2b4f31722e47f" - }, - "future_inputs": [ - "normal_registry_sdk_identity", - "complete_advisory_enforced_dependency_graph", - "sealed_archive_inventory" - ] - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/tests/fixtures/static-credential-consumer/dependency-intent.json b/tests/fixtures/static-credential-consumer/dependency-intent.json new file mode 100644 index 00000000..dbe3a801 --- /dev/null +++ b/tests/fixtures/static-credential-consumer/dependency-intent.json @@ -0,0 +1,33 @@ +{ + "schema": "durable-workflow.php-static-credential-intent/v1", + "requirements": { + "php": "8.4.27", + "durable-workflow/sdk": "3.10.0", + "laravel/framework": "12.69.3", + "symfony/framework-bundle": "7.4.21" + }, + "framework_source_references": { + "laravel/framework": "58ea544a2a80dc03c168e13a5dc9a1d176a88717", + "symfony/framework-bundle": "62b38e9cb02c944e01030898b3d2b4f31722e47f" + }, + "fixture": { + "sha256": "0d4f01b763d72850943acbd9a74a0cc5da1ca6db5bc1b6fa5ca37fda11810acb", + "bytes": 60716, + "unique_ids": 319 + }, + "dependency_policy": { + "plugins": false, + "scripts": false, + "advisory_policy": "normal" + }, + "execution": { + "network": "none", + "transport": "refusing_spy", + "worker_loop": false + }, + "future_inputs": [ + "normal_registry_sdk_identity", + "complete_advisory_enforced_dependency_graph", + "sealed_archive_inventory" + ] +} From af7eb5c2fc1a437760fb4beb3b44c29ee9db8625 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:26:50 +0000 Subject: [PATCH 3/5] Checkpoint caller-owned credential fixture runner before P0 docs work --- docs/portal/operate/authentication.md | 7 + scripts/Support/StaticCredentialCases.php | 290 ++++++++++++++++++ .../StaticCredentialFrameworkProbe.php | 226 ++++++++++++++ scripts/Support/StaticCredentialProbe.php | 61 +++- .../Support/StaticCredentialQualification.php | 98 ++++++ scripts/Support/StaticCredentialSubject.php | 256 ++++++++++++++++ scripts/qualify-static-credentials.php | 53 ++++ scripts/static-credentials.md | 11 +- tests/StaticCredentialProbeTest.php | 13 +- tests/StaticCredentialQualificationTest.php | 159 ++++++++++ tests/StaticCredentialSubjectTest.php | 111 +++++++ .../static-credential-consumer/autoload.php | 7 + .../subject-template.json | 11 + 13 files changed, 1285 insertions(+), 18 deletions(-) create mode 100644 scripts/Support/StaticCredentialCases.php create mode 100644 scripts/Support/StaticCredentialFrameworkProbe.php create mode 100644 scripts/Support/StaticCredentialQualification.php create mode 100644 scripts/Support/StaticCredentialSubject.php create mode 100644 scripts/qualify-static-credentials.php create mode 100644 tests/StaticCredentialQualificationTest.php create mode 100644 tests/StaticCredentialSubjectTest.php create mode 100644 tests/fixtures/static-credential-consumer/autoload.php create mode 100644 tests/fixtures/static-credential-consumer/subject-template.json diff --git a/docs/portal/operate/authentication.md b/docs/portal/operate/authentication.md index 83dd1214..310b2f7d 100644 --- a/docs/portal/operate/authentication.md +++ b/docs/portal/operate/authentication.md @@ -67,6 +67,13 @@ an anonymous `Client` remains available where it was already supported. An empty scoped token can fall back to a valid shared token, but a malformed nonempty scoped token is an error. +The bridge's existing shared-versus-scoped mode rule still applies. +`ServiceConfiguration::fromArray()` treats literal-empty optional settings as +absence. Its direct constructor retains the exact supplied properties, +including empty strings, and does not normalize them before its mode check. +Use `null` or omission for an absent constructor argument; an empty shared +argument combined with scoped arguments still conflicts with that mode rule. + Errors identify only the setting, for example `Invalid static credential setting DURABLE_WORKFLOW_WORKER_TOKEN.` They do not include the rejected value. Keep that boundary in application error diff --git a/scripts/Support/StaticCredentialCases.php b/scripts/Support/StaticCredentialCases.php new file mode 100644 index 00000000..1903373e --- /dev/null +++ b/scripts/Support/StaticCredentialCases.php @@ -0,0 +1,290 @@ +> */ + public function core(): iterable + { + foreach ($this->scalarIds() as $id) { + foreach (self::CORE as $target) { + $roles = $target === 'ServiceConfigurationAlias' ? ['shared', 'control'] : self::ROLES; + foreach ($roles as $role) { + if (!$this->rejectsNul($target, $id)) { + yield $this->scalar($id, $target, $role); + } + } + } + } + yield from $this->coreAdapters(); + yield from $this->controls(); + } + + /** @return iterable> */ + public function frameworks(): iterable + { + foreach (StaticCredentialFrameworkProbe::targets() as $target => $metadata) { + foreach ($this->scalarIds() as $id) { + foreach (self::ROLES as $role) { + if (!$this->rejectsNul($target, $id)) { + yield $this->scalar($id, $target, $role, $metadata['plane']); + } + } + } + yield from $this->frameworkAdapters($target, $metadata); + yield from $this->frameworkControls($target, $metadata['plane']); + } + } + + /** @return list> */ + public function exclusions(): array + { + $targets = ['ProcessCredentialResolver']; + foreach (StaticCredentialFrameworkProbe::targets() as $target => $metadata) { + if ($metadata['framework'] === 'laravel') { + $targets[] = $target; + } + } + $excluded = []; + foreach ($this->scalarIds() as $id) { + foreach ($targets as $target) { + if ($this->rejectsNul($target, $id)) { + $excluded[] = ['id' => $id, 'target' => $target, 'reason' => 'os_environment_cannot_represent_nul']; + } + } + } + return $excluded; + } + + /** @return iterable */ + private function scalarIds(): iterable + { + foreach (['common_scalar_cases', 'php_byte_loader_cases'] as $group) { + foreach ($this->fixture->data[$group] as $case) { + yield $case['id']; + } + } + } + + /** @return array */ + private function scalar(string $id, string $target, string $role, ?string $plane = null): array + { + $value = $this->fixture->value($id); + $ordinary = $this->fixture->value('scalar-ordinary'); + $plane ??= $role === 'worker' ? 'worker' : 'control'; + $options = match ($role) { + 'shared' => ['shared' => $value], + 'control' => ['control' => $value, 'worker' => $ordinary], + 'worker' => ['worker' => $value, 'control' => $ordinary], + default => throw new StaticCredentialProbeFailure('Unsupported scalar credential role.'), + }; + if ($value === '') { + $options = $role === 'shared' ? ['shared' => '', 'control' => $ordinary, 'worker' => $ordinary] + : ['shared' => $ordinary, $role => '']; + } + $selected = ($role === 'shared' || $role === $plane) && $value !== '' ? $value : $ordinary; + $output = ['Authorization' => 'Bearer '.$selected]; + if ($target === 'ServiceConfigurationConstructor') { + // Raw constructor settings preserve empty strings; unlike fromArray they do not normalize them. + $options = [$role => $value]; + $output = ['token' => $options['shared'] ?? null, 'controlToken' => $options['control'] ?? null, + 'workerToken' => $options['worker'] ?? null]; + } + return ['id' => $id, 'target' => $target, 'role' => $role, 'plane' => $plane, 'options' => $options, + 'process' => [], 'expected' => match ($this->fixture->scalar($id)['expected']) { + 'preserve' => 'preserve', 'reject', 'reject-invalid-utf8' => 'reject', + default => throw new StaticCredentialProbeFailure('Unsupported scalar expectation.'), + }, + 'output' => $output, 'setting' => $this->setting($target, $role)]; + } + + /** @return iterable, string, string}> */ + private function adapters(): iterable + { + foreach (['pre_selection_cases', 'php_adapter_cases'] as $group) { + foreach ($this->fixture->data[$group] as $case) { + $targets = $group === 'pre_selection_cases' ? ['Client', 'TokenAuthentication'] + : ($case['targets'] ?? [$case['target']]); + foreach ($targets as $target) { + yield [$case, $target, $group]; + } + } + } + } + + /** @return iterable> */ + private function coreAdapters(): iterable + { + $seen = []; + foreach ($this->adapters() as [$case, $target]) { + foreach ($this->adapterTargets($target, $case) as $adapted) { + $key = $case['id'].'/'.$adapted; + if (!isset($seen[$key])) { + $seen[$key] = true; + yield $this->adapter($case, $adapted); + } + } + } + } + + /** + * @param array $case + * @return list + */ + private function adapterTargets(string $target, array $case): array + { + // The frozen shared-plus-scoped pre-selection rows belong only to their original direct targets. + if ($target !== 'ServiceConfiguration') { + return [$target]; + } + return ($case['selected_plane'] ?? null) === 'control' + ? [$target, 'ServiceConfigurationConstructor', 'ServiceConfigurationAlias'] + : [$target, 'ServiceConfigurationConstructor']; + } + + /** + * @param array $metadata + * @return iterable> + */ + private function frameworkAdapters(string $target, array $metadata): iterable + { + $seen = []; + foreach ($this->adapters() as [$case, $original, $family]) { + $plane = $case['selected_plane'] ?? ($case['process_role'] === 'worker' ? 'worker' : 'control'); + $applies = $family === 'php_adapter_cases' + && $original === ($metadata['framework'] === 'laravel' ? 'ProcessCredentialResolver' : 'ServiceConfiguration'); + if ($plane === $metadata['plane'] && $applies && !isset($seen[$case['id']])) { + $seen[$case['id']] = true; + yield $this->adapter($case, $target); + } + } + } + + /** + * @param array $case + * @return array + */ + private function adapter(array $case, string $target): array + { + $options = $this->adapterOptions($case); + $process = isset($case['process_token_case_ref']) ? ['DURABLE_WORKFLOW_PROCESS_ROLE' => $case['process_role'], + 'DURABLE_WORKFLOW_PROCESS_TOKEN' => $this->fixture->value($case['process_token_case_ref'])] : []; + $plane = $case['selected_plane'] ?? ($case['process_role'] === 'worker' ? 'worker' : 'control'); + $expected = match ($case['expected']) { + 'reject', 'reject-credential' => 'reject', + 'existing-role-refusal-before-request' => 'role-refusal', + 'preserve-selected-text' => 'preserve', + default => throw new StaticCredentialProbeFailure('Unsupported adapter expectation.'), + }; + $setting = $expected === 'reject' ? ($case['offending_setting'] ?? $this->setting($target, $case['offending_option'])) : null; + return ['id' => $case['id'], 'target' => $target, 'role' => 'adapter', 'plane' => $plane, + 'options' => $options, 'process' => $process, 'expected' => $expected, 'setting' => $setting, + 'output' => $expected === 'preserve' ? ['Authorization' => 'Bearer '.$this->fixture->value($case['expected_case_ref'])] : []]; + } + + /** + * @param array $case + * @return array + */ + private function adapterOptions(array $case): array + { + $options = []; + foreach ($case['options_case_refs'] ?? [] as $role => $reference) { + $options[$role] = $this->fixture->value($reference); + } + foreach ($case['options_code_points'] ?? [] as $role => $points) { + $options[$role] = StaticCredentialFixture::points($points); + } + foreach ($case['null_options'] ?? [] as $role) { + $options[$role] = null; + } + foreach ($case['empty_options'] ?? [] as $role) { + $options[$role] = ''; + } + return $options; + } + + private function rejectsNul(string $target, string $id): bool + { + $environment = $target === 'ProcessCredentialResolver' + || (StaticCredentialFrameworkProbe::targets()[$target]['framework'] ?? null) === 'laravel'; + return $environment && str_contains($this->fixture->value($id), "\0"); + } + + private function setting(string $target, string $role): string + { + if (in_array($target, ['Client', 'TokenAuthentication'], true)) { + return ['shared' => 'token', 'control' => 'controlToken', 'worker' => 'workerToken'][$role]; + } + if ($target === 'ProcessCredentialResolver' || $target === 'ProcessRole' + || (StaticCredentialFrameworkProbe::targets()[$target]['framework'] ?? null) === 'laravel') { + return StaticCredentialProbe::ENVIRONMENT[$role]; + } + return 'credentials.'.['shared' => 'token', 'control' => 'control_token', 'worker' => 'worker_token'][$role]; + } + + /** @return iterable> */ + private function controls(): iterable + { + foreach (['Client', 'ServiceConfiguration', 'ServiceConfigurationAlias', 'ProcessCredentialResolver'] as $target) { + yield ['id' => 'control-anonymous', 'target' => $target, 'role' => 'control', 'plane' => 'control', + 'options' => [], 'process' => [], 'expected' => 'preserve', 'output' => [], 'setting' => null]; + } + yield ['id' => 'control-required-token', 'target' => 'TokenAuthentication', 'role' => 'control', 'plane' => 'control', + 'options' => [], 'process' => [], 'expected' => 'required-token', 'output' => [], 'setting' => null]; + yield ['id' => 'control-absent-constructor', 'target' => 'ServiceConfigurationConstructor', 'role' => 'control', 'plane' => 'control', + 'options' => [], 'process' => [], 'expected' => 'preserve', + 'output' => ['token' => null, 'controlToken' => null, 'workerToken' => null], 'setting' => null]; + foreach (['', $this->fixture->value('scalar-ordinary')] as $shared) { + yield ['id' => $shared === '' ? 'control-raw-empty-mode' : 'control-raw-mixed-mode', + 'target' => 'ServiceConfigurationConstructor', 'role' => 'control', 'plane' => 'control', + 'options' => ['shared' => $shared, 'control' => $this->fixture->value('scalar-ordinary')], + 'process' => [], 'expected' => 'mode-refusal', 'output' => [], 'setting' => null]; + } + foreach (['php-valid-scoped-mode-unused-worker-scalar', 'php-valid-scoped-mode-unused-control-invalid-utf8', + 'php-process-handoff-worker-scalar', 'php-process-handoff-worker-invalid-utf8'] as $id) { + yield $this->adapter($this->fixture->adapter($id), 'ProcessRole'); + } + foreach (['shared', 'worker'] as $mode) { + yield ['id' => 'control-process-mode-'.$mode, 'target' => 'ProcessRole', 'role' => $mode, 'plane' => 'worker', + 'options' => [$mode => $this->fixture->value('scalar-ordinary')], 'process' => [], 'expected' => 'preserve', + 'output' => ['credential_role' => $mode], 'setting' => null]; + } + foreach ([['ServiceConfiguration', 'control'], ['ServiceConfiguration', 'worker'], + ['ServiceConfigurationAlias', 'control'], ['ProcessCredentialResolver', 'control'], + ['ProcessCredentialResolver', 'worker']] as [$target, $plane]) { + yield from $this->frameworkControls($target, $plane, false); + } + } + + /** @return iterable> */ + private function frameworkControls(string $target, string $plane, bool $anonymous = true): iterable + { + if ($anonymous) { + yield ['id' => 'control-anonymous', 'target' => $target, 'role' => 'none', 'plane' => $plane, + 'options' => [], 'process' => [], 'expected' => 'preserve', 'output' => [], 'setting' => null]; + } + $opposite = $plane === 'worker' ? 'control' : 'worker'; + $ordinary = $this->fixture->value('scalar-ordinary'); + yield ['id' => 'control-opposite-only', 'target' => $target, 'role' => $opposite, 'plane' => $plane, + 'options' => [$opposite => $ordinary], 'process' => [], 'expected' => 'role-refusal', 'output' => [], 'setting' => null]; + yield ['id' => 'control-existing-mode', 'target' => $target, 'role' => $plane, 'plane' => $plane, + 'options' => ['shared' => $ordinary, $plane => $ordinary], 'process' => [], + 'expected' => 'mode-refusal', 'output' => [], 'setting' => null]; + } +} diff --git a/scripts/Support/StaticCredentialFrameworkProbe.php b/scripts/Support/StaticCredentialFrameworkProbe.php new file mode 100644 index 00000000..7415ceba --- /dev/null +++ b/scripts/Support/StaticCredentialFrameworkProbe.php @@ -0,0 +1,226 @@ + */ + public static function targets(): array + { + return [ + 'LaravelClient' => ['framework' => 'laravel', 'plane' => 'control', 'surface' => 'client', 'cached' => false], + 'LaravelInterfaces' => ['framework' => 'laravel', 'plane' => 'control', 'surface' => 'interfaces', 'cached' => false], + 'LaravelFacade' => ['framework' => 'laravel', 'plane' => 'control', 'surface' => 'facade', 'cached' => false], + 'LaravelWorkerFactory' => ['framework' => 'laravel', 'plane' => 'worker', 'surface' => 'worker', 'cached' => false], + 'LaravelCachedClient' => ['framework' => 'laravel', 'plane' => 'control', 'surface' => 'client', 'cached' => true], + 'LaravelCachedWorkerFactory' => ['framework' => 'laravel', 'plane' => 'worker', 'surface' => 'worker', 'cached' => true], + 'SymfonyClient' => ['framework' => 'symfony', 'plane' => 'control', 'surface' => 'client', 'cached' => false], + 'SymfonyWorkerFactory' => ['framework' => 'symfony', 'plane' => 'worker', 'surface' => 'worker', 'cached' => false], + ]; + } + + /** + * @param array $options + * @param array $process + * @return array + */ + public static function headers(string $target, array $options, string $plane, StaticCredentialTransportSpy $spy, array $process = []): array + { + $metadata = self::targets()[$target] ?? null; + if ($metadata === null || $metadata['plane'] !== $plane) { + throw new StaticCredentialProbeFailure('Unsupported framework credential target.'); + } + if ($metadata['framework'] === 'laravel') { + if (!class_exists(Application::class)) { + throw new StaticCredentialProbeFailure('The proposed Laravel framework is unavailable.'); + } + return StaticCredentialProbe::environment(StaticCredentialProbe::processValues($options, $process), + static fn (): array => self::laravel($metadata, $spy)); + } + if ($process !== [] || !class_exists(FrameworkBundle::class)) { + throw new StaticCredentialProbeFailure('The proposed Symfony framework is unavailable or unsupported.'); + } + return self::symfony($metadata['surface'], $options, $plane, $spy); + } + + /** + * @param array{framework: string, plane: string, surface: string, cached: bool} $metadata + * @return array + */ + private static function laravel(array $metadata, StaticCredentialTransportSpy $spy): array + { + $previousContainer = Container::getInstance(); + $previousFacade = Facade::getFacadeApplication(); + $timezone = date_default_timezone_get(); + $encoding = function_exists('mb_internal_encoding') ? mb_internal_encoding() : null; + $base = self::directory(); + $app = null; + try { + $app = new Application($base); + $values = ['runtime_url' => 'https://fixture.invalid', 'namespace' => 'fixture', + 'task_queue' => 'fixture', 'handlers' => []]; + $app->instance('config', new Repository(['durable-workflow' => $values])); + if ($metadata['cached']) { + self::cachedConfiguration($app, $base, $values); + } + $app->afterResolving(Client::class, static function (Client $client) use ($spy): void { + $spy->attach($client); + }); + (new DurableWorkflowServiceProvider($app))->register(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($app); + $client = self::laravelClient($app, $metadata['surface']); + $spy->attach($client); + return StaticCredentialProbe::authentication($client)?->headers($metadata['plane'] === 'worker') ?? []; + } finally { + $app?->flush(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($previousFacade); + Container::setInstance($previousContainer); + date_default_timezone_set($timezone); + if ($encoding !== null) { + mb_internal_encoding($encoding); + } + self::removeDirectory($base); + } + } + + private static function laravelClient(Application $app, string $surface): Client + { + if ($surface === 'worker') { + return self::factoryClient($app->make(LaravelWorkerFactory::class)); + } + if ($surface === 'client') { + return $app->make(Client::class); + } + $lazy = $app->make(WorkflowClientInterface::class); + if ($app->resolved(Client::class) || $lazy instanceof Client) { + throw new StaticCredentialProbeFailure('Laravel interface construction was not credential-lazy.'); + } + if ($surface === 'facade') { + $root = WorkflowFacade::getFacadeRoot(); + if (!$root instanceof LaravelWorkflowClientInterface + || (new ReflectionProperty(LaravelWorkflowClient::class, 'client'))->getValue($root) !== $lazy + || $app->resolved(Client::class)) { + throw new StaticCredentialProbeFailure('Laravel facade lost its lazy workflow client.'); + } + } + $handle = $lazy->workflowHandle('fixture'); + $client = $app->make(Client::class); + if ((new ReflectionObject($handle))->getProperty('client')->getValue($handle) !== $client) { + throw new StaticCredentialProbeFailure('Laravel local handle used a different client.'); + } + return $client; + } + + /** @param array $values */ + private static function cachedConfiguration(Application $app, string $base, array $values): void + { + $app->useBootstrapPath($base.'/bootstrap'); + $path = $app->getCachedConfigPath(); + if ($path !== $base.'/bootstrap/cache/config.php' || array_key_exists('credentials', $values)) { + throw new StaticCredentialProbeFailure('Unexpected fixture configuration cache boundary.'); + } + if (!mkdir($base.'/bootstrap', 0700) || !mkdir($base.'/bootstrap/cache', 0700)) { + throw new StaticCredentialProbeFailure('Fixture cache directory creation failed.'); + } + $bytes = ' $values, + 'app' => ['env' => 'production', 'timezone' => 'UTC']], true).";\n"; + if (file_put_contents($path, $bytes) !== strlen($bytes) || !chmod($path, 0600)) { + throw new StaticCredentialProbeFailure('Fixture configuration cache creation failed.'); + } + (new LoadConfiguration())->bootstrap($app); + if (!$app->configurationIsCached() || $app->make('config')->get('durable-workflow.credentials') !== null) { + throw new StaticCredentialProbeFailure('Fixture cached configuration retained credential settings.'); + } + } + + /** + * @param array $options + * @return array + */ + private static function symfony(string $surface, array $options, string $plane, StaticCredentialTransportSpy $spy): array + { + $credentials = []; + foreach (['shared' => 'token', 'control' => 'control_token', 'worker' => 'worker_token'] as $role => $name) { + if (array_key_exists($role, $options)) { + $credentials[$name] = $options[$role]; + } + } + $container = new ContainerBuilder(); + $bundle = new DurableWorkflowBundle(); + $bundle->build($container); + $extension = $bundle->getContainerExtension(); + if (!$extension instanceof DurableWorkflowExtension) { + throw new StaticCredentialProbeFailure('Unexpected Symfony bundle extension.'); + } + $extension->load([['endpoint' => 'https://fixture.invalid', 'namespace' => 'fixture', + 'task_queue' => 'fixture', 'handlers' => [], 'credentials' => $credentials]], $container); + $container->compile(); + $client = $surface === 'worker' + ? self::factoryClient($container->get(SymfonyWorkerFactory::class)) : $container->get(Client::class); + if (!$client instanceof Client) { + throw new StaticCredentialProbeFailure('Symfony did not construct the selected role client.'); + } + if ($surface === 'client' && $container->get(WorkflowClientInterface::class) !== $client) { + throw new StaticCredentialProbeFailure('Symfony workflow interface used a different application client.'); + } + $spy->attach($client); + return StaticCredentialProbe::authentication($client)?->headers($plane === 'worker') ?? []; + } + + private static function factoryClient(object $factory): Client + { + $client = (new ReflectionObject($factory))->getProperty('client')->getValue($factory); + if (!$client instanceof Client) { + throw new StaticCredentialProbeFailure('Framework worker factory did not retain a client.'); + } + return $client; + } + + private static function directory(): string + { + $path = tempnam(sys_get_temp_dir(), 'sdk-php-credential-cache-'); + if ($path === false || !unlink($path) || !mkdir($path, 0700)) { + throw new StaticCredentialProbeFailure('Fixture private directory creation failed.'); + } + return $path; + } + + private static function removeDirectory(string $base): void + { + foreach ([$base.'/bootstrap/cache/config.php', $base.'/bootstrap/cache', $base.'/bootstrap', $base] as $path) { + if (is_file($path)) { + unlink($path); + } elseif (is_dir($path)) { + rmdir($path); + } + } + } +} diff --git a/scripts/Support/StaticCredentialProbe.php b/scripts/Support/StaticCredentialProbe.php index 51d52018..9334a2b2 100644 --- a/scripts/Support/StaticCredentialProbe.php +++ b/scripts/Support/StaticCredentialProbe.php @@ -39,7 +39,7 @@ public static function headers(string $target, array $options, string $plane, Tr $client = match ($target) { 'Client' => new Client('https://fixture.invalid', transport: $transport, token: $options['shared'] ?? null, controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null), - 'ServiceConfiguration', 'ServiceConfigurationConstructor', 'ServiceConfigurationAlias' => + 'ServiceConfiguration', 'ServiceConfigurationAlias' => self::configured($target, $options, $plane, $transport), 'ProcessCredentialResolver' => self::process($options, $process, $plane, $transport), default => throw new StaticCredentialProbeFailure('Unsupported credential target.'), @@ -58,6 +58,18 @@ public static function authentication(Client $client): ?Authentication return $authentication; } + /** + * @param array $options + * @return array + */ + public static function constructor(array $options): array + { + $configuration = new ServiceConfiguration('https://fixture.invalid', 'fixture', 'fixture', [], + $options['shared'] ?? null, $options['control'] ?? null, $options['worker'] ?? null); + return ['token' => $configuration->token, 'controlToken' => $configuration->controlToken, + 'workerToken' => $configuration->workerToken]; + } + /** * @template T * @param array $values @@ -95,17 +107,12 @@ public static function environment(array $values, callable $build): mixed /** @param array $options */ private static function configured(string $target, array $options, string $plane, Transport $transport): Client { - if ($target === 'ServiceConfigurationConstructor') { - $configuration = new ServiceConfiguration('https://fixture.invalid', 'fixture', 'fixture', [], - $options['shared'] ?? null, $options['control'] ?? null, $options['worker'] ?? null); - } else { - $credentials = []; - foreach ($options as $role => $value) { - $credentials[self::CONFIGURATION[$role]] = $value; - } - $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', - 'namespace' => 'fixture', 'task_queue' => 'fixture', 'handlers' => [], 'credentials' => $credentials]); + $credentials = []; + foreach ($options as $role => $value) { + $credentials[self::CONFIGURATION[$role]] = $value; } + $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', + 'namespace' => 'fixture', 'task_queue' => 'fixture', 'handlers' => [], 'credentials' => $credentials]); if ($target === 'ServiceConfigurationAlias') { if ($plane !== 'control') { throw new StaticCredentialProbeFailure('The configuration alias is a control client.'); @@ -120,16 +127,40 @@ private static function configured(string $target, array $options, string $plane * @param array $values */ private static function process(array $options, array $values, string $plane, Transport $transport): Client + { + $values = self::processValues($options, $values); + $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', 'task_queue' => 'fixture']); + return self::environment($values, static fn (): Client => $plane === 'worker' + ? ProcessCredentialResolver::workerClient($configuration, $transport) + : ProcessCredentialResolver::applicationClient($configuration, $transport)); + } + + /** + * @param array $options + * @param array $values + * @return array + */ + public static function processValues(array $options, array $values = []): array { foreach ($options as $role => $value) { + if (!isset(self::ENVIRONMENT[$role])) { + throw new StaticCredentialProbeFailure('Unsupported fixture credential role.'); + } if ($value !== null) { $values[self::ENVIRONMENT[$role]] = $value; } } - $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', 'task_queue' => 'fixture']); - return self::environment($values, static fn (): Client => $plane === 'worker' - ? ProcessCredentialResolver::workerClient($configuration, $transport) - : ProcessCredentialResolver::applicationClient($configuration, $transport)); + return $values; + } + + /** + * @param array $options + * @param array $process + */ + public static function workerRole(array $options, array $process = []): string + { + return self::environment(self::processValues($options, $process), + static fn (): string => ProcessCredentialResolver::workerCredentialRole()); } private static function put(string $setting): void diff --git a/scripts/Support/StaticCredentialQualification.php b/scripts/Support/StaticCredentialQualification.php new file mode 100644 index 00000000..41eb2180 --- /dev/null +++ b/scripts/Support/StaticCredentialQualification.php @@ -0,0 +1,98 @@ + $case + * @return array + */ + public static function check(array $case): array + { + $spy = new StaticCredentialTransportSpy(); + $output = null; + $error = null; + try { + $output = match (true) { + $case['target'] === 'ServiceConfigurationConstructor' => StaticCredentialProbe::constructor($case['options']), + $case['target'] === 'ProcessRole' => ['credential_role' => StaticCredentialProbe::workerRole($case['options'], $case['process'])], + isset(StaticCredentialFrameworkProbe::targets()[$case['target']]) => + StaticCredentialFrameworkProbe::headers($case['target'], $case['options'], $case['plane'], $spy, $case['process']), + default => StaticCredentialProbe::headers($case['target'], $case['options'], $case['plane'], $spy, $case['process']), + }; + } catch (Throwable $caught) { + $error = $caught; + } + return self::verdict($case, $output, $error, $spy->attempts); + } + + /** + * @param array $case + * @param array|null $output + * @return array + */ + public static function verdict(array $case, ?array $output, ?Throwable $error, int $attempts): array + { + $setting = self::setting($error); + $pass = $attempts === 0 && match ($case['expected']) { + 'preserve' => $error === null && $output === $case['output'], + 'reject' => $error instanceof InvalidArgumentException && $setting !== null && $setting === $case['setting'], + 'required-token' => $error instanceof InvalidArgumentException + && $error->getMessage() === 'At least one non-empty authentication token is required.', + 'role-refusal' => self::roleRefusal($case, $error), + 'mode-refusal' => self::modeRefusal($case, $error), + default => false, + }; + return ['id' => $case['id'], 'target' => $case['target'], 'role' => $case['role'], 'plane' => $case['plane'], + 'verdict' => $pass ? 'PASS' : 'FAIL', 'setting' => $setting, 'transport_attempts' => $attempts, + 'failure' => $pass ? null : ($attempts === 0 ? 'fixture_mismatch' : 'transport_attempted')]; + } + + private static function setting(?Throwable $error): ?string + { + if (!$error instanceof InvalidArgumentException + || preg_match('/\AInvalid static credential setting ([A-Za-z][A-Za-z0-9_.]{0,127})\.\z/', $error->getMessage(), $matches) !== 1) { + return null; + } + $allowed = ['token', 'controlToken', 'workerToken', 'credentials.token', 'credentials.control_token', + 'credentials.worker_token', ...array_values(StaticCredentialProbe::ENVIRONMENT), 'DURABLE_WORKFLOW_PROCESS_TOKEN']; + return in_array($matches[1], $allowed, true) ? $matches[1] : null; + } + + /** @param array $case */ + private static function roleRefusal(array $case, ?Throwable $error): bool + { + $laravel = $case['target'] === 'ProcessCredentialResolver' + || (StaticCredentialFrameworkProbe::targets()[$case['target']]['framework'] ?? null) === 'laravel'; + $role = $case['plane']; + $message = match (true) { + $laravel && $role === 'control' => 'A client credential is required for the Laravel application client. Configure the self-hosted shared token or scoped client token.', + $laravel => 'A worker credential is required for the Laravel worker client. Configure the self-hosted shared token or scoped worker token.', + in_array($case['target'], ['Client', 'TokenAuthentication'], true) => + 'A '.$role.' credential is required for this request. Configure a shared token or the scoped '.$role.' token.', + $role === 'control' => 'A control credential is required for the application client. Configure the shared token or scoped control token.', + default => 'A worker credential is required for the worker client. Configure the shared token or scoped worker token.', + }; + return $error instanceof InvalidArgumentException && $error->getMessage() === $message; + } + + /** @param array $case */ + private static function modeRefusal(array $case, ?Throwable $error): bool + { + $laravel = $case['target'] === 'ProcessCredentialResolver' + || (StaticCredentialFrameworkProbe::targets()[$case['target']]['framework'] ?? null) === 'laravel'; + $message = $laravel + ? 'Configure either the self-hosted shared Durable Workflow token or scoped client and worker tokens, not both.' + : 'Configure either the shared Durable Workflow token or scoped control and worker tokens, not both.'; + return $error instanceof InvalidArgumentException && $error->getMessage() === $message; + } +} diff --git a/scripts/Support/StaticCredentialSubject.php b/scripts/Support/StaticCredentialSubject.php new file mode 100644 index 00000000..d250a65c --- /dev/null +++ b/scripts/Support/StaticCredentialSubject.php @@ -0,0 +1,256 @@ + $intent + * @param array $data + */ + private function __construct(public readonly array $intent, public readonly array $data, private readonly string $vendor) + { + } + + public static function assertFreshProcess(): void + { + foreach (['Composer\\InstalledVersions', 'DurableWorkflow\\Client', 'DurableWorkflow\\SdkIdentity', + 'DurableWorkflow\\Auth\\TokenAuthentication', 'DurableWorkflow\\Auth\\StaticCredential', + 'DurableWorkflow\\Bridge\\ServiceConfiguration', 'DurableWorkflow\\Bridge\\Laravel\\ProcessCredentialResolver', + 'DurableWorkflow\\Bridge\\Laravel\\DurableWorkflowServiceProvider', + 'DurableWorkflow\\Bridge\\Symfony\\DurableWorkflowBundle', 'DurableWorkflow\\Transport\\Psr18Transport', + 'Illuminate\\Foundation\\Application', 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle'] as $class) { + if (class_exists($class, false)) { + throw new StaticCredentialProbeFailure('Consumer package classes were already loaded.'); + } + } + } + + /** @return array */ + public static function intent(string $path): array + { + $intent = self::json($path); + $names = ['php', 'durable-workflow/sdk', 'laravel/framework', 'symfony/framework-bundle']; + if (($intent['schema'] ?? null) !== 'durable-workflow.php-static-credential-intent/v1' + || !is_array($intent['requirements'] ?? null) || count($intent['requirements']) !== count($names) + || array_diff(array_keys($intent['requirements']), $names) !== [] + || ($intent['fixture']['sha256'] ?? null) !== StaticCredentialFixture::SHA256 + || ($intent['dependency_policy'] ?? null) !== ['plugins' => false, 'scripts' => false, 'advisory_policy' => 'normal']) { + throw new StaticCredentialProbeFailure('The dependency intent differs.'); + } + foreach ($intent['requirements'] as $version) { + if (!is_string($version) || preg_match('/\A\d+\.\d+\.\d+\z/', $version) !== 1) { + throw new StaticCredentialProbeFailure('Dependency intent requires exact stable versions.'); + } + } + foreach (['laravel/framework', 'symfony/framework-bundle'] as $name) { + self::digest($intent['framework_source_references'][$name] ?? null, 40); + } + return $intent; + } + + public static function read(string $autoload, string $intentPath, string $subjectPath, string $subjectSha256): self + { + self::digest($subjectSha256, 64); + $raw = self::bytes($subjectPath, $subjectSha256, 65536); + $data = self::object($raw); + if (json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)."\n" !== $raw) { + throw new StaticCredentialProbeFailure('The caller-owned subject must use canonical JSON.'); + } + $fields = ['schema', 'runner_reference', 'sdk_reference', 'lock_sha256', 'archive_inventory_file', + 'archive_inventory_sha256', 'sdk_archive_file', 'sdk_archive_sha256', 'sdk_archive_bytes']; + if (($data['schema'] ?? null) !== 'durable-workflow.php-static-credential-subject/v1' + || array_diff(array_keys($data), $fields) !== []) { + throw new StaticCredentialProbeFailure('The subject envelope differs.'); + } + foreach (['runner_reference', 'sdk_reference'] as $name) { + self::digest($data[$name] ?? null, 40); + } + foreach (['lock_sha256', 'archive_inventory_sha256', 'sdk_archive_sha256'] as $name) { + self::digest($data[$name] ?? null, 64); + } + if (!is_int($data['sdk_archive_bytes'] ?? null) || $data['sdk_archive_bytes'] < 1 || $data['sdk_archive_bytes'] > 134217728) { + throw new StaticCredentialProbeFailure('The SDK archive size is unclosed.'); + } + $intent = self::intent($intentPath); + $vendor = realpath(dirname($autoload)); + if ($vendor === false || basename($vendor) !== 'vendor' || realpath($autoload) !== $vendor.'/autoload.php') { + throw new StaticCredentialProbeFailure('The consumer autoloader boundary differs.'); + } + $lock = self::object(self::bytes(dirname($vendor).'/composer.lock', $data['lock_sha256'], 4194304)); + self::receiptFiles($subjectPath, $data); + self::project(dirname($vendor), $intent, $data, $lock); + return new self($intent, $data, $vendor); + } + + /** @return array */ + public function installed(): array + { + $metadata = (new ReflectionClass(InstalledVersions::class))->getFileName(); + if (!is_string($metadata) || realpath($metadata) !== $this->vendor.'/composer/InstalledVersions.php') { + throw new StaticCredentialProbeFailure('Installed metadata came from another consumer.'); + } + $packages = []; + foreach (['durable-workflow/sdk', 'laravel/framework', 'symfony/framework-bundle'] as $name) { + $expected = $this->intent['requirements'][$name]; + $actual = InstalledVersions::getPrettyVersion($name); + $reference = InstalledVersions::getReference($name); + $selected = $name === 'durable-workflow/sdk' ? $this->data['sdk_reference'] + : $this->intent['framework_source_references'][$name]; + $path = InstalledVersions::getInstallPath($name); + if (!in_array($actual, [$expected, 'v'.$expected], true) || $reference !== $selected + || !is_string($path) || realpath($path) !== $this->vendor.'/'.$name) { + throw new StaticCredentialProbeFailure('The installed package identity differs.'); + } + $packages[$name] = ['version' => $actual, 'reference' => $reference]; + } + $paths = $this->implementations(); + $manifest = self::json($this->vendor.'/durable-workflow/sdk/composer.json'); + if (($manifest['extra']['durable-workflow']['product-train'] ?? null) !== $this->intent['requirements']['durable-workflow/sdk'] + || SdkIdentity::version() !== $this->intent['requirements']['durable-workflow/sdk']) { + throw new StaticCredentialProbeFailure('Installed release metadata differs.'); + } + return ['packages' => $packages, 'implementation_paths' => $paths, 'caller_runner_reference' => $this->data['runner_reference'], + 'lock_sha256' => $this->data['lock_sha256'], 'caller_archive_inventory_sha256' => $this->data['archive_inventory_sha256']]; + } + + /** @return array */ + private function implementations(): array + { + $paths = []; + $classes = ['DurableWorkflow\\Client', 'DurableWorkflow\\SdkIdentity', 'DurableWorkflow\\Auth\\TokenAuthentication', + 'DurableWorkflow\\Auth\\StaticCredential', 'DurableWorkflow\\Bridge\\ServiceConfiguration', + 'DurableWorkflow\\Bridge\\Laravel\\ProcessCredentialResolver', + 'DurableWorkflow\\Bridge\\Laravel\\DurableWorkflowServiceProvider', + 'DurableWorkflow\\Bridge\\Symfony\\DurableWorkflowBundle', 'DurableWorkflow\\Transport\\Psr18Transport']; + foreach ($classes as $class) { + $file = (new ReflectionClass($class))->getFileName(); + $actual = is_string($file) ? realpath($file) : false; + if ($actual === false || !str_starts_with($actual, $this->vendor.'/durable-workflow/sdk/src/')) { + throw new StaticCredentialProbeFailure('A credential implementation came from outside the installed SDK.'); + } + $paths[$class] = substr($actual, strlen($this->vendor) + 1); + } + $frameworks = ['Illuminate\\Foundation\\Application' => 'laravel/framework', + 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle' => 'symfony/framework-bundle']; + foreach ($frameworks as $class => $package) { + if (!class_exists($class)) { + throw new StaticCredentialProbeFailure('A required framework implementation is absent.'); + } + $file = (new ReflectionClass($class))->getFileName(); + $actual = is_string($file) ? realpath($file) : false; + if ($actual === false || !str_starts_with($actual, $this->vendor.'/'.$package.'/')) { + throw new StaticCredentialProbeFailure('A framework implementation came from another package.'); + } + $paths[$class] = substr($actual, strlen($this->vendor) + 1); + } + return $paths; + } + + /** + * @param array $intent + * @param array $subject + * @param array $lock + */ + private static function project(string $root, array $intent, array $subject, array $lock): void + { + $project = self::json($root.'/composer.json'); + $actual = $project['require'] ?? []; + $expected = $intent['requirements']; + if (!is_array($actual)) { + throw new StaticCredentialProbeFailure('Consumer requirements must be an object.'); + } + ksort($actual); + ksort($expected); + if ($actual !== $expected || ($project['repositories'] ?? []) !== [] + || ($project['scripts'] ?? []) !== [] || ($project['config']['allow-plugins'] ?? null) !== false + || isset($project['config']['audit']['ignore']) || ($project['config']['audit']['block-insecure'] ?? true) !== true) { + throw new StaticCredentialProbeFailure('Consumer intent or normal advisory policy differs.'); + } + foreach ($lock['packages'] ?? [] as $package) { + if (($package['name'] ?? null) === 'durable-workflow/sdk' + && ($package['version'] ?? null) === $intent['requirements']['durable-workflow/sdk'] + && ($package['dist']['type'] ?? null) === 'zip' + && ($package['dist']['reference'] ?? null) === $subject['sdk_reference'] + && ($package['dist']['url'] ?? null) === 'https://api.github.com/repos/durable-workflow/sdk-php/zipball/'.$subject['sdk_reference']) { + return; + } + } + throw new StaticCredentialProbeFailure('No normal registry SDK distribution is bound in the lock.'); + } + + /** @param array $data */ + private static function receiptFiles(string $subjectPath, array $data): void + { + $root = realpath(dirname($subjectPath)); + foreach (['archive_inventory', 'sdk_archive'] as $name) { + $leaf = $data[$name.'_file'] ?? null; + if (!is_string($leaf) || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/', $leaf) !== 1) { + throw new StaticCredentialProbeFailure('Receipt names must be packet-local files.'); + } + $path = realpath(dirname($subjectPath).'/'.$leaf); + if ($root === false || $path === false || dirname($path) !== $root) { + throw new StaticCredentialProbeFailure('Receipt file escaped the packet.'); + } + self::fileHash($path, $data[$name.'_sha256'], 134217728); + if ($name === 'sdk_archive' && filesize($path) !== $data['sdk_archive_bytes']) { + throw new StaticCredentialProbeFailure('The SDK archive size differs.'); + } + } + } + + private static function digest(mixed $value, int $length): void + { + if (!is_string($value) || preg_match('/\A[a-f0-9]{'.$length.'}\z/', $value) !== 1) { + throw new StaticCredentialProbeFailure('A future immutable reference is missing.'); + } + } + + private static function fileHash(string $path, string $sha256, int $maximum): void + { + if (!is_file($path) || !is_readable($path) || filesize($path) > $maximum || hash_file('sha256', $path) !== $sha256) { + throw new StaticCredentialProbeFailure('A caller-owned receipt file differs.'); + } + } + + private static function bytes(string $path, string $sha256, int $maximum): string + { + if (!is_file($path) || !is_readable($path) || filesize($path) > $maximum) { + throw new StaticCredentialProbeFailure('A caller-owned input is missing or too large.'); + } + $bytes = (string) file_get_contents($path); + if (strlen($bytes) > $maximum || hash('sha256', $bytes) !== $sha256) { + throw new StaticCredentialProbeFailure('The consumed input bytes differ.'); + } + return $bytes; + } + + /** @return array */ + private static function json(string $path): array + { + if (!is_file($path) || !is_readable($path)) { + throw new StaticCredentialProbeFailure('A caller-owned input is missing.'); + } + return self::object((string) file_get_contents($path)); + } + + /** @return array */ + private static function object(string $bytes): array + { + $value = json_decode($bytes, true, flags: JSON_THROW_ON_ERROR); + if (!is_array($value) || array_is_list($value)) { + throw new StaticCredentialProbeFailure('A caller-owned object is required.'); + } + return $value; + } +} diff --git a/scripts/qualify-static-credentials.php b/scripts/qualify-static-credentials.php new file mode 100644 index 00000000..0b1dd31c --- /dev/null +++ b/scripts/qualify-static-credentials.php @@ -0,0 +1,53 @@ +intent['requirements']['php']) { + throw new StaticCredentialProbeFailure('The proposed PHP interpreter differs.'); + } + $fixture = new StaticCredentialFixture(dirname(__DIR__).'/tests/fixtures/auth-configuration/static-credentials-v1.json'); + require $argv[1]; + $facts = $subject->installed(); + require_once __DIR__.'/Support/StaticCredentialQualification.php'; + $phase = 'fixtures'; + $cases = new StaticCredentialCases($fixture); + $count = 0; + $failed = 0; + foreach ([$cases->core(), $cases->frameworks()] as $group) { + foreach ($group as $case) { + $result = StaticCredentialQualification::check($case); + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n"; + ++$count; + $failed += $result['verdict'] === 'PASS' ? 0 : 1; + } + } + $notPhp = [$fixture->data['binary_key_boundary']['id'], ...array_column($fixture->data['server_pre_selection_cases'], 'id')]; + echo json_encode(['qualification' => 'static-credential-fixtures-only', 'verdict' => $failed === 0 ? 'PASS' : 'FAIL', + 'cases' => $count, 'failed' => $failed, 'subject' => $facts, 'fixture_sha256' => StaticCredentialFixture::SHA256, + 'environment_input_exclusions' => $cases->exclusions(), 'not_php_scalar_cases' => $notPhp, + 'caller_preconditions_not_proved_here' => ['complete_advisory_enforced_graph', 'full_archive_and_installed_file_seal', + 'execution_admission', 'native_or_runtime_authentication']], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n"; + exit($failed === 0 ? 0 : 1); +} catch (Throwable) { + fwrite(STDERR, json_encode(['qualification' => 'static-credential-fixtures-only', 'verdict' => 'REFUSED', 'phase' => $phase], + JSON_THROW_ON_ERROR)."\n"); + exit(2); +} diff --git a/scripts/static-credentials.md b/scripts/static-credentials.md index 2578e110..4f3e6573 100644 --- a/scripts/static-credentials.md +++ b/scripts/static-credentials.md @@ -31,13 +31,20 @@ control and worker requests and require the spy to intercept them. Constructors remain inside the caller's network-isolated boundary; the fixture runner never starts a worker loop or transmits credential headers. -The caller-owned runner is being authored to cover direct public credential +The draft caller-owned runner covers direct public credential construction, both service configuration paths, Laravel's process resolver and real provider/lazy-client/worker-factory paths, and Symfony's real bundle/client/ -worker-factory paths. It will compare the fixture's original bytes and exact +worker-factory paths. It compares the fixture's original bytes and exact setting-only refusals with a refusing transport spy and no worker loop. It will not run a runtime smoke suite, acquire tools or manage a runtime/controller. +The source checkpoint is preserved while the P0 documentation redesign (#315) +takes priority. The pure preflight, core fixture matrix and refusal controls have +source coverage. Positive Laravel/Symfony execution on the proposed complete +dependency graph, installed metadata acceptance, final runner documentation and +normal current-head qualification remain unfinished. The template intentionally +has no future immutable references and must refuse before consumer autoload. + The offline loader/regression controls use an existing source vendor: ```sh diff --git a/tests/StaticCredentialProbeTest.php b/tests/StaticCredentialProbeTest.php index 2dd9a33e..08d91e9f 100644 --- a/tests/StaticCredentialProbeTest.php +++ b/tests/StaticCredentialProbeTest.php @@ -98,11 +98,22 @@ public function testConstructionPathsPreserveOriginalBytesAndRefuseUnusedText(st /** @return iterable */ public static function configurationTargets(): iterable { - foreach (['ServiceConfiguration', 'ServiceConfigurationConstructor', 'ServiceConfigurationAlias'] as $target) { + foreach (['ServiceConfiguration', 'ServiceConfigurationAlias'] as $target) { yield $target => [$target]; } } + public function testRawConstructorRetainsItsEmptySettingAndModeBoundary(): void + { + self::assertSame(['token' => '', 'controlToken' => null, 'workerToken' => null], + StaticCredentialProbe::constructor(['shared' => ''])); + self::assertSame(['token' => null, 'controlToken' => "fixture-e\u{0301}", 'workerToken' => null], + StaticCredentialProbe::constructor(['control' => "fixture-e\u{0301}"])); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Configure either the shared Durable Workflow token or scoped control and worker tokens, not both.'); + StaticCredentialProbe::constructor(['shared' => '', 'control' => 'fixture-only']); + } + public function testUnknownPlanesDoNotFallBackToControl(): void { $this->expectException(StaticCredentialProbeFailure::class); diff --git a/tests/StaticCredentialQualificationTest.php b/tests/StaticCredentialQualificationTest.php new file mode 100644 index 00000000..592f27ca --- /dev/null +++ b/tests/StaticCredentialQualificationTest.php @@ -0,0 +1,159 @@ +core() as $case) { + $key = self::key($case); + self::assertArrayNotHasKey($key, $seen); + $seen[$key] = true; + $result = StaticCredentialQualification::check($case); + self::assertSame('PASS', $result['verdict'], $key.' / '.json_encode($result)); + self::assertSame(0, $result['transport_attempts']); + if (isset($scalarIds[$case['id']])) { + $scalars[$case['target']] = ($scalars[$case['target']] ?? 0) + 1; + } + if (in_array($case['id'], ['malformed-unused-worker', 'malformed-unused-control', 'malformed-masked-shared'], true)) { + self::assertContains($case['target'], ['Client', 'TokenAuthentication']); + } + ++$count; + } + self::assertGreaterThan(5000, $count); + self::assertSame(['Client' => 888, 'TokenAuthentication' => 888, 'ServiceConfiguration' => 888, + 'ServiceConfigurationConstructor' => 888, 'ServiceConfigurationAlias' => 592, 'ProcessCredentialResolver' => 879], $scalars); + } + + public function testFrameworkInventoryRetainsTheSelectedPlaneAndOriginalEnvironmentBytes(): void + { + $targets = StaticCredentialFrameworkProbe::targets(); + $seen = []; + $counts = []; + $scalars = []; + $scalarIds = self::scalarIds(); + foreach (self::cases()->frameworks() as $case) { + $key = self::key($case); + self::assertArrayNotHasKey($key, $seen); + $seen[$key] = true; + self::assertSame($targets[$case['target']]['plane'], $case['plane']); + self::assertContains($case['expected'], ['preserve', 'reject', 'role-refusal', 'mode-refusal']); + self::assertNotContains($case['id'], ['malformed-unused-worker', 'malformed-unused-control', 'malformed-masked-shared', + 'php-control-scoped-before-shared', 'php-worker-scoped-before-shared']); + if (isset($scalarIds[$case['id']])) { + $scalars[$case['target']] = ($scalars[$case['target']] ?? 0) + 1; + } + if ($targets[$case['target']]['framework'] === 'laravel') { + foreach ($case['options'] as $value) { + self::assertTrue($value === null || !str_contains($value, "\0")); + } + } + $counts[$case['target']] = ($counts[$case['target']] ?? 0) + 1; + } + self::assertEqualsCanonicalizing(array_keys($targets), array_keys($counts)); + foreach ($counts as $count) { + self::assertGreaterThan(800, $count); + } + foreach ($targets as $target => $metadata) { + self::assertSame($metadata['framework'] === 'laravel' ? 879 : 888, $scalars[$target]); + } + foreach (self::cases()->exclusions() as $excluded) { + self::assertSame('os_environment_cannot_represent_nul', $excluded['reason']); + } + } + + public function testNativeDiagnosticValuesAndAdditionalFieldsCannotReachReports(): void + { + $case = self::ordinary(); + $sentinel = 'fixture-value-must-stay-private'; + $case['expected'] = 'reject'; + $case['setting'] = 'token'; + $error = new InvalidArgumentException('Invalid static credential setting token. '.$sentinel); + $result = StaticCredentialQualification::verdict($case, null, $error, 0); + self::assertSame('FAIL', $result['verdict']); + self::assertNull($result['setting']); + self::assertStringNotContainsString($sentinel, (string) json_encode($result)); + $exact = StaticCredentialQualification::verdict($case, null, + new InvalidArgumentException('Invalid static credential setting token.'), 0); + self::assertSame('PASS', $exact['verdict']); + self::assertSame('token', $exact['setting']); + } + + public function testHeaderSubstitutionAndTransportAttemptsCannotPass(): void + { + $case = self::ordinary(); + $extra = $case['output'] + ['X-Fixture-Unexpected' => 'fixture-value-must-stay-private']; + $result = StaticCredentialQualification::verdict($case, $extra, null, 0); + self::assertSame('FAIL', $result['verdict']); + self::assertStringNotContainsString('fixture-value-must-stay-private', (string) json_encode($result)); + $attempted = StaticCredentialQualification::verdict($case, $case['output'], null, 1); + self::assertSame('FAIL', $attempted['verdict']); + self::assertSame('transport_attempted', $attempted['failure']); + $case['expected'] = 'unsupported'; + self::assertSame('FAIL', StaticCredentialQualification::verdict($case, $case['output'], null, 0)['verdict']); + } + + #[DataProvider('missingFrameworks')] + public function testUnavailableFrameworksRefuseInsteadOfFallingBack(string $target, string $packageClass, string $plane): void + { + if (class_exists($packageClass)) { + self::markTestSkipped('The missing-framework negative requires an absent optional package.'); + } + $this->expectException(StaticCredentialProbeFailure::class); + StaticCredentialFrameworkProbe::headers($target, [], $plane, new StaticCredentialTransportSpy()); + } + + /** @return iterable */ + public static function missingFrameworks(): iterable + { + yield 'Laravel' => ['LaravelClient', 'Illuminate\\Foundation\\Application', 'control']; + yield 'Symfony' => ['SymfonyClient', 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle', 'control']; + } + + /** @param array $case */ + private static function key(array $case): string + { + return $case['id'].'/'.$case['target'].'/'.$case['plane'].'/'.$case['role']; + } + + private static function cases(): StaticCredentialCases + { + return new StaticCredentialCases(new StaticCredentialFixture(__DIR__.'/fixtures/auth-configuration/static-credentials-v1.json')); + } + + /** @return array */ + private static function scalarIds(): array + { + $fixture = new StaticCredentialFixture(__DIR__.'/fixtures/auth-configuration/static-credentials-v1.json'); + return array_fill_keys([...array_column($fixture->data['common_scalar_cases'], 'id'), + ...array_column($fixture->data['php_byte_loader_cases'], 'id')], true); + } + + /** @return array */ + private static function ordinary(): array + { + foreach (self::cases()->core() as $case) { + return $case; + } + throw new StaticCredentialProbeFailure('The core fixture inventory is empty.'); + } +} diff --git a/tests/StaticCredentialSubjectTest.php b/tests/StaticCredentialSubjectTest.php new file mode 100644 index 00000000..d49394bc --- /dev/null +++ b/tests/StaticCredentialSubjectTest.php @@ -0,0 +1,111 @@ + false, 'scripts' => false, 'advisory_policy' => 'normal'], $intent['dependency_policy']); + } + + public function testAnUnclosedTemplateCannotReachConsumerCode(): void + { + $path = __DIR__.'/fixtures/static-credential-consumer/subject-template.json'; + $this->expectException(StaticCredentialProbeFailure::class); + $this->expectExceptionMessage('A future immutable reference is missing.'); + StaticCredentialSubject::read(__DIR__.'/fixtures/static-credential-consumer/autoload.php', self::intentPath(), + $path, (string) hash_file('sha256', $path)); + } + + public function testAnAlreadyLoadedSourceClientCannotBeAnInstalledCliSubject(): void + { + self::assertTrue(class_exists(Client::class)); + $this->expectException(StaticCredentialProbeFailure::class); + StaticCredentialSubject::assertFreshProcess(); + } + + #[DataProvider('changedInputs')] + public function testRawAndUnclosedSubjectInputsRefuseBeforeAutoload(string $kind): void + { + $bytes = (string) file_get_contents(__DIR__.'/fixtures/static-credential-consumer/subject-template.json'); + $bytes = match ($kind) { + 'duplicate' => str_replace(' "runner_reference": null,', ' "runner_reference": null,' ."\n".' "runner_reference": null,', $bytes), + 'escaped duplicate' => str_replace(' "runner_reference": null,', ' "runner_reference": null,' ."\n".' "runner_\u0072eference": null,', $bytes), + 'trailing' => $bytes.'{}', + 'invalid UTF8' => $bytes."\xff", + 'extra field' => str_replace(' "runner_reference": null,', ' "unknown": "fixture-only",' ."\n".' "runner_reference": null,', $bytes), + 'hash mismatch' => $bytes, + default => throw new \LogicException('Unknown source control.'), + }; + $path = tempnam(sys_get_temp_dir(), 'sdk-php-credential-subject-'); + self::assertIsString($path); + try { + self::assertSame(strlen($bytes), file_put_contents($path, $bytes)); + $hash = $kind === 'hash mismatch' ? str_repeat('0', 64) : hash('sha256', $bytes); + $refused = null; + try { + StaticCredentialSubject::read(__DIR__.'/fixtures/static-credential-consumer/autoload.php', self::intentPath(), $path, $hash); + } catch (Throwable $error) { + $refused = $error; + } + self::assertInstanceOf(Throwable::class, $refused); + } finally { + unlink($path); + } + } + + /** @return iterable */ + public static function changedInputs(): iterable + { + foreach (['duplicate', 'escaped duplicate', 'trailing', 'invalid UTF8', 'extra field', 'hash mismatch'] as $kind) { + yield $kind => [$kind]; + } + } + + public function testRangesAndAdvisoryPolicyChangesAreNotTheSelectedIntent(): void + { + $intent = json_decode((string) file_get_contents(self::intentPath()), true, flags: JSON_THROW_ON_ERROR); + foreach (['range', 'plugins', 'scripts', 'advisories'] as $change) { + $candidate = $intent; + if ($change === 'range') { + $candidate['requirements']['durable-workflow/sdk'] = '^3.10'; + } else { + $key = $change === 'advisories' ? 'advisory_policy' : $change; + $candidate['dependency_policy'][$key] = true; + } + $path = tempnam(sys_get_temp_dir(), 'sdk-php-credential-intent-'); + self::assertIsString($path); + try { + file_put_contents($path, json_encode($candidate, JSON_THROW_ON_ERROR)); + $refused = false; + try { StaticCredentialSubject::intent($path); } + catch (StaticCredentialProbeFailure) { $refused = true; } + self::assertTrue($refused); + } finally { + unlink($path); + } + } + } + + private static function intentPath(): string + { + return __DIR__.'/fixtures/static-credential-consumer/dependency-intent.json'; + } +} diff --git a/tests/fixtures/static-credential-consumer/autoload.php b/tests/fixtures/static-credential-consumer/autoload.php new file mode 100644 index 00000000..24c3e78c --- /dev/null +++ b/tests/fixtures/static-credential-consumer/autoload.php @@ -0,0 +1,7 @@ + Date: Fri, 6 Nov 2026 00:28:10 +0000 Subject: [PATCH 4/5] Group credential fixture runner as conformance test support --- .../Support/StaticCredentialCases.php | 0 .../Support/StaticCredentialFixture.php | 0 .../StaticCredentialFrameworkProbe.php | 7 +-- .../Support/StaticCredentialProbe.php | 9 ++-- .../Support/StaticCredentialProbeFailure.php | 0 .../Support/StaticCredentialQualification.php | 6 ++- .../Support/StaticCredentialSubject.php | 49 ++++++++++++------- .../Support/StaticCredentialTransportSpy.php | 0 .../qualify-static-credentials.php | 8 +-- .../{ => conformance}/static-credentials.md | 43 +++++++++++++--- tests/StaticCredentialFixtureTest.php | 2 +- tests/StaticCredentialProbeTest.php | 4 +- tests/StaticCredentialQualificationTest.php | 2 +- tests/StaticCredentialSubjectTest.php | 36 +++++++++++++- tests/StaticCredentialValidationTest.php | 4 +- 15 files changed, 125 insertions(+), 45 deletions(-) rename scripts/{ => conformance}/Support/StaticCredentialCases.php (100%) rename scripts/{ => conformance}/Support/StaticCredentialFixture.php (100%) rename scripts/{ => conformance}/Support/StaticCredentialFrameworkProbe.php (97%) rename scripts/{ => conformance}/Support/StaticCredentialProbe.php (95%) rename scripts/{ => conformance}/Support/StaticCredentialProbeFailure.php (100%) rename scripts/{ => conformance}/Support/StaticCredentialQualification.php (96%) rename scripts/{ => conformance}/Support/StaticCredentialSubject.php (88%) rename scripts/{ => conformance}/Support/StaticCredentialTransportSpy.php (100%) rename scripts/{ => conformance}/qualify-static-credentials.php (86%) rename scripts/{ => conformance}/static-credentials.md (64%) diff --git a/scripts/Support/StaticCredentialCases.php b/scripts/conformance/Support/StaticCredentialCases.php similarity index 100% rename from scripts/Support/StaticCredentialCases.php rename to scripts/conformance/Support/StaticCredentialCases.php diff --git a/scripts/Support/StaticCredentialFixture.php b/scripts/conformance/Support/StaticCredentialFixture.php similarity index 100% rename from scripts/Support/StaticCredentialFixture.php rename to scripts/conformance/Support/StaticCredentialFixture.php diff --git a/scripts/Support/StaticCredentialFrameworkProbe.php b/scripts/conformance/Support/StaticCredentialFrameworkProbe.php similarity index 97% rename from scripts/Support/StaticCredentialFrameworkProbe.php rename to scripts/conformance/Support/StaticCredentialFrameworkProbe.php index 7415ceba..d4aa3887 100644 --- a/scripts/Support/StaticCredentialFrameworkProbe.php +++ b/scripts/conformance/Support/StaticCredentialFrameworkProbe.php @@ -30,6 +30,7 @@ /** Real provider/bundle construction; no handler execution, worker make/run or runtime request. */ final class StaticCredentialFrameworkProbe { + private const BOOTSTRAP_DIRECTORY = '/bootstrap'; /** @return array */ public static function targets(): array { @@ -142,12 +143,12 @@ private static function laravelClient(Application $app, string $surface): Client /** @param array $values */ private static function cachedConfiguration(Application $app, string $base, array $values): void { - $app->useBootstrapPath($base.'/bootstrap'); + $app->useBootstrapPath($base.self::BOOTSTRAP_DIRECTORY); $path = $app->getCachedConfigPath(); if ($path !== $base.'/bootstrap/cache/config.php' || array_key_exists('credentials', $values)) { throw new StaticCredentialProbeFailure('Unexpected fixture configuration cache boundary.'); } - if (!mkdir($base.'/bootstrap', 0700) || !mkdir($base.'/bootstrap/cache', 0700)) { + if (!mkdir($base.self::BOOTSTRAP_DIRECTORY, 0700) || !mkdir($base.'/bootstrap/cache', 0700)) { throw new StaticCredentialProbeFailure('Fixture cache directory creation failed.'); } $bytes = ' $values, @@ -215,7 +216,7 @@ private static function directory(): string private static function removeDirectory(string $base): void { - foreach ([$base.'/bootstrap/cache/config.php', $base.'/bootstrap/cache', $base.'/bootstrap', $base] as $path) { + foreach ([$base.'/bootstrap/cache/config.php', $base.'/bootstrap/cache', $base.self::BOOTSTRAP_DIRECTORY, $base] as $path) { if (is_file($path)) { unlink($path); } elseif (is_dir($path)) { diff --git a/scripts/Support/StaticCredentialProbe.php b/scripts/conformance/Support/StaticCredentialProbe.php similarity index 95% rename from scripts/Support/StaticCredentialProbe.php rename to scripts/conformance/Support/StaticCredentialProbe.php index 9334a2b2..e1f83cc9 100644 --- a/scripts/Support/StaticCredentialProbe.php +++ b/scripts/conformance/Support/StaticCredentialProbe.php @@ -17,6 +17,7 @@ /** Construction/header inspection only; no client request or worker loop. */ final class StaticCredentialProbe { + public const ENDPOINT = 'https://fixture.invalid'; public const ENVIRONMENT = ['shared' => 'DURABLE_WORKFLOW_TOKEN', 'control' => 'DURABLE_WORKFLOW_CLIENT_TOKEN', 'worker' => 'DURABLE_WORKFLOW_WORKER_TOKEN']; private const CONFIGURATION = ['shared' => 'token', 'control' => 'control_token', 'worker' => 'worker_token']; @@ -37,7 +38,7 @@ public static function headers(string $target, array $options, string $plane, Tr controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null); } else { $client = match ($target) { - 'Client' => new Client('https://fixture.invalid', transport: $transport, + 'Client' => new Client(self::ENDPOINT, transport: $transport, token: $options['shared'] ?? null, controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null), 'ServiceConfiguration', 'ServiceConfigurationAlias' => self::configured($target, $options, $plane, $transport), @@ -64,7 +65,7 @@ public static function authentication(Client $client): ?Authentication */ public static function constructor(array $options): array { - $configuration = new ServiceConfiguration('https://fixture.invalid', 'fixture', 'fixture', [], + $configuration = new ServiceConfiguration(self::ENDPOINT, 'fixture', 'fixture', [], $options['shared'] ?? null, $options['control'] ?? null, $options['worker'] ?? null); return ['token' => $configuration->token, 'controlToken' => $configuration->controlToken, 'workerToken' => $configuration->workerToken]; @@ -111,7 +112,7 @@ private static function configured(string $target, array $options, string $plane foreach ($options as $role => $value) { $credentials[self::CONFIGURATION[$role]] = $value; } - $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', + $configuration = ServiceConfiguration::fromArray(['endpoint' => self::ENDPOINT, 'namespace' => 'fixture', 'task_queue' => 'fixture', 'handlers' => [], 'credentials' => $credentials]); if ($target === 'ServiceConfigurationAlias') { if ($plane !== 'control') { @@ -129,7 +130,7 @@ private static function configured(string $target, array $options, string $plane private static function process(array $options, array $values, string $plane, Transport $transport): Client { $values = self::processValues($options, $values); - $configuration = ServiceConfiguration::fromArray(['endpoint' => 'https://fixture.invalid', 'task_queue' => 'fixture']); + $configuration = ServiceConfiguration::fromArray(['endpoint' => self::ENDPOINT, 'task_queue' => 'fixture']); return self::environment($values, static fn (): Client => $plane === 'worker' ? ProcessCredentialResolver::workerClient($configuration, $transport) : ProcessCredentialResolver::applicationClient($configuration, $transport)); diff --git a/scripts/Support/StaticCredentialProbeFailure.php b/scripts/conformance/Support/StaticCredentialProbeFailure.php similarity index 100% rename from scripts/Support/StaticCredentialProbeFailure.php rename to scripts/conformance/Support/StaticCredentialProbeFailure.php diff --git a/scripts/Support/StaticCredentialQualification.php b/scripts/conformance/Support/StaticCredentialQualification.php similarity index 96% rename from scripts/Support/StaticCredentialQualification.php rename to scripts/conformance/Support/StaticCredentialQualification.php index 41eb2180..825f15f5 100644 --- a/scripts/Support/StaticCredentialQualification.php +++ b/scripts/conformance/Support/StaticCredentialQualification.php @@ -52,9 +52,13 @@ public static function verdict(array $case, ?array $output, ?Throwable $error, i 'mode-refusal' => self::modeRefusal($case, $error), default => false, }; + $failure = $pass ? null : 'fixture_mismatch'; + if ($attempts !== 0) { + $failure = 'transport_attempted'; + } return ['id' => $case['id'], 'target' => $case['target'], 'role' => $case['role'], 'plane' => $case['plane'], 'verdict' => $pass ? 'PASS' : 'FAIL', 'setting' => $setting, 'transport_attempts' => $attempts, - 'failure' => $pass ? null : ($attempts === 0 ? 'fixture_mismatch' : 'transport_attempted')]; + 'failure' => $failure]; } private static function setting(?Throwable $error): ?string diff --git a/scripts/Support/StaticCredentialSubject.php b/scripts/conformance/Support/StaticCredentialSubject.php similarity index 88% rename from scripts/Support/StaticCredentialSubject.php rename to scripts/conformance/Support/StaticCredentialSubject.php index d250a65c..dd3077d5 100644 --- a/scripts/Support/StaticCredentialSubject.php +++ b/scripts/conformance/Support/StaticCredentialSubject.php @@ -14,6 +14,9 @@ /** Binds caller-owned receipt bytes and installed identity; it does not solve, install or seal a graph. */ final class StaticCredentialSubject { + private const SDK_PACKAGE = 'durable-workflow/sdk'; + private const LARAVEL_PACKAGE = 'laravel/framework'; + private const SYMFONY_PACKAGE = 'symfony/framework-bundle'; /** * @param array $intent * @param array $data @@ -40,7 +43,7 @@ public static function assertFreshProcess(): void public static function intent(string $path): array { $intent = self::json($path); - $names = ['php', 'durable-workflow/sdk', 'laravel/framework', 'symfony/framework-bundle']; + $names = ['php', self::SDK_PACKAGE, self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE]; if (($intent['schema'] ?? null) !== 'durable-workflow.php-static-credential-intent/v1' || !is_array($intent['requirements'] ?? null) || count($intent['requirements']) !== count($names) || array_diff(array_keys($intent['requirements']), $names) !== [] @@ -53,7 +56,7 @@ public static function intent(string $path): array throw new StaticCredentialProbeFailure('Dependency intent requires exact stable versions.'); } } - foreach (['laravel/framework', 'symfony/framework-bundle'] as $name) { + foreach ([self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE] as $name) { self::digest($intent['framework_source_references'][$name] ?? null, 40); } return $intent; @@ -101,11 +104,11 @@ public function installed(): array throw new StaticCredentialProbeFailure('Installed metadata came from another consumer.'); } $packages = []; - foreach (['durable-workflow/sdk', 'laravel/framework', 'symfony/framework-bundle'] as $name) { + foreach ([self::SDK_PACKAGE, self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE] as $name) { $expected = $this->intent['requirements'][$name]; $actual = InstalledVersions::getPrettyVersion($name); $reference = InstalledVersions::getReference($name); - $selected = $name === 'durable-workflow/sdk' ? $this->data['sdk_reference'] + $selected = $name === self::SDK_PACKAGE ? $this->data['sdk_reference'] : $this->intent['framework_source_references'][$name]; $path = InstalledVersions::getInstallPath($name); if (!in_array($actual, [$expected, 'v'.$expected], true) || $reference !== $selected @@ -116,8 +119,8 @@ public function installed(): array } $paths = $this->implementations(); $manifest = self::json($this->vendor.'/durable-workflow/sdk/composer.json'); - if (($manifest['extra']['durable-workflow']['product-train'] ?? null) !== $this->intent['requirements']['durable-workflow/sdk'] - || SdkIdentity::version() !== $this->intent['requirements']['durable-workflow/sdk']) { + if (($manifest['extra']['durable-workflow']['product-train'] ?? null) !== $this->intent['requirements'][self::SDK_PACKAGE] + || SdkIdentity::version() !== $this->intent['requirements'][self::SDK_PACKAGE]) { throw new StaticCredentialProbeFailure('Installed release metadata differs.'); } return ['packages' => $packages, 'implementation_paths' => $paths, 'caller_runner_reference' => $this->data['runner_reference'], @@ -134,22 +137,17 @@ private function implementations(): array 'DurableWorkflow\\Bridge\\Laravel\\DurableWorkflowServiceProvider', 'DurableWorkflow\\Bridge\\Symfony\\DurableWorkflowBundle', 'DurableWorkflow\\Transport\\Psr18Transport']; foreach ($classes as $class) { - $file = (new ReflectionClass($class))->getFileName(); - $actual = is_string($file) ? realpath($file) : false; - if ($actual === false || !str_starts_with($actual, $this->vendor.'/durable-workflow/sdk/src/')) { + $actual = self::implementationFile($class); + if (!str_starts_with($actual, $this->vendor.'/durable-workflow/sdk/src/')) { throw new StaticCredentialProbeFailure('A credential implementation came from outside the installed SDK.'); } $paths[$class] = substr($actual, strlen($this->vendor) + 1); } - $frameworks = ['Illuminate\\Foundation\\Application' => 'laravel/framework', - 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle' => 'symfony/framework-bundle']; + $frameworks = ['Illuminate\\Foundation\\Application' => self::LARAVEL_PACKAGE, + 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle' => self::SYMFONY_PACKAGE]; foreach ($frameworks as $class => $package) { - if (!class_exists($class)) { - throw new StaticCredentialProbeFailure('A required framework implementation is absent.'); - } - $file = (new ReflectionClass($class))->getFileName(); - $actual = is_string($file) ? realpath($file) : false; - if ($actual === false || !str_starts_with($actual, $this->vendor.'/'.$package.'/')) { + $actual = self::implementationFile($class); + if (!str_starts_with($actual, $this->vendor.'/'.$package.'/')) { throw new StaticCredentialProbeFailure('A framework implementation came from another package.'); } $paths[$class] = substr($actual, strlen($this->vendor) + 1); @@ -157,6 +155,19 @@ private function implementations(): array return $paths; } + private static function implementationFile(string $class): string + { + if (!class_exists($class)) { + throw new StaticCredentialProbeFailure('A required consumer implementation is absent.'); + } + $file = (new ReflectionClass($class))->getFileName(); + $actual = is_string($file) ? realpath($file) : false; + if ($actual === false) { + throw new StaticCredentialProbeFailure('A consumer implementation has no regular source file.'); + } + return $actual; + } + /** * @param array $intent * @param array $subject @@ -178,8 +189,8 @@ private static function project(string $root, array $intent, array $subject, arr throw new StaticCredentialProbeFailure('Consumer intent or normal advisory policy differs.'); } foreach ($lock['packages'] ?? [] as $package) { - if (($package['name'] ?? null) === 'durable-workflow/sdk' - && ($package['version'] ?? null) === $intent['requirements']['durable-workflow/sdk'] + if (($package['name'] ?? null) === self::SDK_PACKAGE + && ($package['version'] ?? null) === $intent['requirements'][self::SDK_PACKAGE] && ($package['dist']['type'] ?? null) === 'zip' && ($package['dist']['reference'] ?? null) === $subject['sdk_reference'] && ($package['dist']['url'] ?? null) === 'https://api.github.com/repos/durable-workflow/sdk-php/zipball/'.$subject['sdk_reference']) { diff --git a/scripts/Support/StaticCredentialTransportSpy.php b/scripts/conformance/Support/StaticCredentialTransportSpy.php similarity index 100% rename from scripts/Support/StaticCredentialTransportSpy.php rename to scripts/conformance/Support/StaticCredentialTransportSpy.php diff --git a/scripts/qualify-static-credentials.php b/scripts/conformance/qualify-static-credentials.php similarity index 86% rename from scripts/qualify-static-credentials.php rename to scripts/conformance/qualify-static-credentials.php index 0b1dd31c..94b79f2c 100644 --- a/scripts/qualify-static-credentials.php +++ b/scripts/conformance/qualify-static-credentials.php @@ -9,7 +9,7 @@ use DurableWorkflow\Qualification\StaticCredentialSubject; if (PHP_SAPI !== 'cli' || $argc !== 4) { - fwrite(STDERR, "Usage: php scripts/qualify-static-credentials.php AUTOLOAD SUBJECT_JSON SUBJECT_SHA256\n"); + fwrite(STDERR, "Usage: php scripts/conformance/qualify-static-credentials.php AUTOLOAD SUBJECT_JSON SUBJECT_SHA256\n"); exit(2); } @@ -18,13 +18,13 @@ $phase = 'preflight'; try { StaticCredentialSubject::assertFreshProcess(); - $subject = StaticCredentialSubject::read($argv[1], dirname(__DIR__).'/tests/fixtures/static-credential-consumer/dependency-intent.json', + $subject = StaticCredentialSubject::read($argv[1], dirname(__DIR__, 2).'/tests/fixtures/static-credential-consumer/dependency-intent.json', $argv[2], $argv[3]); if (PHP_VERSION !== $subject->intent['requirements']['php']) { throw new StaticCredentialProbeFailure('The proposed PHP interpreter differs.'); } - $fixture = new StaticCredentialFixture(dirname(__DIR__).'/tests/fixtures/auth-configuration/static-credentials-v1.json'); - require $argv[1]; + $fixture = new StaticCredentialFixture(dirname(__DIR__, 2).'/tests/fixtures/auth-configuration/static-credentials-v1.json'); + require_once $argv[1]; $facts = $subject->installed(); require_once __DIR__.'/Support/StaticCredentialQualification.php'; $phase = 'fixtures'; diff --git a/scripts/static-credentials.md b/scripts/conformance/static-credentials.md similarity index 64% rename from scripts/static-credentials.md rename to scripts/conformance/static-credentials.md index 4f3e6573..f0dfbda4 100644 --- a/scripts/static-credentials.md +++ b/scripts/conformance/static-credentials.md @@ -5,6 +5,11 @@ This source work prepares the caller-owned check proposed in It has not solved or installed its dependency intent and is not an installed package, HTTP authentication or Server result. +The runner and its instrumentation live under `scripts/conformance`, the +repository's configured test-source scope. The probes inspect clients they +construct from canonical synthetic inputs and attach refusing handlers to those +fixture clients. Production credential validation remains in `src`. + The shared `Support/StaticCredentialFixture.php` loader is also used by the existing source regressions. It verifies the canonical fixture's complete 60,716 bytes, SHA-256 and 319 unique IDs before decoding its PHP inputs. @@ -38,17 +43,19 @@ worker-factory paths. It compares the fixture's original bytes and exact setting-only refusals with a refusing transport spy and no worker loop. It will not run a runtime smoke suite, acquire tools or manage a runtime/controller. -The source checkpoint is preserved while the P0 documentation redesign (#315) -takes priority. The pure preflight, core fixture matrix and refusal controls have -source coverage. Positive Laravel/Symfony execution on the proposed complete -dependency graph, installed metadata acceptance, final runner documentation and -normal current-head qualification remain unfinished. The template intentionally -has no future immutable references and must refuse before consumer autoload. +The pure preflight, core fixture matrix and refusal controls have source +coverage. Positive Laravel/Symfony execution on the proposed complete dependency +graph and installed metadata acceptance remain future work. The template +intentionally has no future immutable references and must refuse before consumer +autoload. A source result on the repository's cached vendor does not qualify the +proposed consumer graph. The offline loader/regression controls use an existing source vendor: ```sh -vendor/bin/phpunit tests/StaticCredentialFixtureTest.php tests/StaticCredentialValidationTest.php +vendor/bin/phpunit tests/StaticCredentialFixtureTest.php \ + tests/StaticCredentialValidationTest.php tests/StaticCredentialProbeTest.php \ + tests/StaticCredentialSubjectTest.php tests/StaticCredentialQualificationTest.php ``` For eventual installed execution, the caller must separately freeze the runner @@ -61,6 +68,28 @@ credential values remain internal to comparisons; only case/target/role, verdict and setting names may be reported. No real credential or endpoint is needed or selected by this source work. +The caller-owned packet preserves these repository-relative paths: + +- `scripts/conformance/qualify-static-credentials.php` +- `scripts/conformance/Support/StaticCredential*.php` +- `tests/fixtures/static-credential-consumer/dependency-intent.json` +- `tests/fixtures/auth-configuration/static-credentials-v1.json` + +The future invocation is: + +```sh +php scripts/conformance/qualify-static-credentials.php \ + /consumer/vendor/autoload.php /packet/subject.json "$SUBJECT_SHA256" +``` + +The external caller binds that subject hash and runner revision before execution. +The subject records the SDK reference, exact lock hash and packet-local archive +receipt names/hashes. The runner verifies those bytes, the registry distribution +identity, installed versions/references and implementation origins. Its final +output explicitly leaves the complete advisory-enforced graph, full archive and +installed-file seal, execution admission and real runtime authentication to the +caller. It does not turn its own inputs into operating permission. + The final combined-minor publication gates on #293 remain independent: published Workflow support authority, published native flat-child-all and the normally installed SDK tuple, and final combined candidate qualification still diff --git a/tests/StaticCredentialFixtureTest.php b/tests/StaticCredentialFixtureTest.php index 780510e3..406b9679 100644 --- a/tests/StaticCredentialFixtureTest.php +++ b/tests/StaticCredentialFixtureTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialFixture.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialFixture.php'; final class StaticCredentialFixtureTest extends TestCase { diff --git a/tests/StaticCredentialProbeTest.php b/tests/StaticCredentialProbeTest.php index 08d91e9f..35489f6b 100644 --- a/tests/StaticCredentialProbeTest.php +++ b/tests/StaticCredentialProbeTest.php @@ -14,8 +14,8 @@ use PHPUnit\Framework\TestCase; use Throwable; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialProbe.php'; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialTransportSpy.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialProbe.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialTransportSpy.php'; final class StaticCredentialProbeTest extends TestCase { diff --git a/tests/StaticCredentialQualificationTest.php b/tests/StaticCredentialQualificationTest.php index 592f27ca..fff68824 100644 --- a/tests/StaticCredentialQualificationTest.php +++ b/tests/StaticCredentialQualificationTest.php @@ -14,7 +14,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialQualification.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialQualification.php'; final class StaticCredentialQualificationTest extends TestCase { diff --git a/tests/StaticCredentialSubjectTest.php b/tests/StaticCredentialSubjectTest.php index d49394bc..7b3402fd 100644 --- a/tests/StaticCredentialSubjectTest.php +++ b/tests/StaticCredentialSubjectTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\TestCase; use Throwable; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialSubject.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialSubject.php'; /** Inert input/refusal controls; no consumer autoload, solve or installed-subject success. */ final class StaticCredentialSubjectTest extends TestCase @@ -41,6 +41,40 @@ public function testAnAlreadyLoadedSourceClientCannotBeAnInstalledCliSubject(): StaticCredentialSubject::assertFreshProcess(); } + public function testCliRefusesUnclosedInputsBeforeExecutingConsumerAutoload(): void + { + $directory = tempnam(sys_get_temp_dir(), 'sdk-php-credential-cli-'); + self::assertIsString($directory); + unlink($directory); + mkdir($directory, 0700); + mkdir($directory.'/vendor', 0700); + $autoload = $directory.'/vendor/autoload.php'; + file_put_contents($autoload, ' ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(2, proc_close($process)); + self::assertSame('', $stdout); + self::assertSame(['qualification' => 'static-credential-fixtures-only', 'verdict' => 'REFUSED', + 'phase' => 'preflight'], json_decode($stderr, true, flags: JSON_THROW_ON_ERROR)); + self::assertFileDoesNotExist($directory.'/vendor/autoload-executed'); + } + } finally { + foreach (glob($directory.'/vendor/*') ?: [] as $file) { + unlink($file); + } + rmdir($directory.'/vendor'); + rmdir($directory); + } + } + #[DataProvider('changedInputs')] public function testRawAndUnclosedSubjectInputsRefuseBeforeAutoload(string $kind): void { diff --git a/tests/StaticCredentialValidationTest.php b/tests/StaticCredentialValidationTest.php index 5f378318..dc2d5b2b 100644 --- a/tests/StaticCredentialValidationTest.php +++ b/tests/StaticCredentialValidationTest.php @@ -17,8 +17,8 @@ use PHPUnit\Framework\TestCase; use ReflectionProperty; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialFixture.php'; -require_once dirname(__DIR__).'/scripts/Support/StaticCredentialProbe.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialFixture.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialProbe.php'; final class StaticCredentialValidationTest extends TestCase { From f221944945fc1ef30e03a23c71e758a89a1553f1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:35:57 +0000 Subject: [PATCH 5/5] Exercise credential fixtures in existing framework source consumers --- .github/workflows/ci.yml | 1 + scripts/conformance/static-credentials.md | 6 ++-- tests/compat/static-credentials.php | 44 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/compat/static-credentials.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb7e7227..b3edad76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -417,6 +417,7 @@ jobs: compatibility_scripts=("tests/compat/${COMPAT_SCRIPT}") fi compatibility_scripts+=(tests/compat/history-cache.php tests/compat/execution-telemetry.php) + php tests/compat/static-credentials.php "$consumer/vendor/autoload.php" "$COMPAT_SCRIPT" "$consumer/vendor/bin/phpstan" analyse -c phpstan-framework.neon \ --autoload-file="$consumer/vendor/autoload.php" \ src/Bridge/FrameworkRuntimeException.php \ diff --git a/scripts/conformance/static-credentials.md b/scripts/conformance/static-credentials.md index f0dfbda4..d1a53164 100644 --- a/scripts/conformance/static-credentials.md +++ b/scripts/conformance/static-credentials.md @@ -44,8 +44,10 @@ setting-only refusals with a refusing transport spy and no worker loop. It will not run a runtime smoke suite, acquire tools or manage a runtime/controller. The pure preflight, core fixture matrix and refusal controls have source -coverage. Positive Laravel/Symfony execution on the proposed complete dependency -graph and installed metadata acceptance remain future work. The template +coverage. The existing framework compatibility cells also run the corresponding +full fixture matrix on their source consumers and report that source scope. +Execution on the proposed complete dependency graph and installed metadata +acceptance remain future work. The template intentionally has no future immutable references and must refuse before consumer autoload. A source result on the repository's cached vendor does not qualify the proposed consumer graph. diff --git a/tests/compat/static-credentials.php b/tests/compat/static-credentials.php new file mode 100644 index 00000000..b3e59f2c --- /dev/null +++ b/tests/compat/static-credentials.php @@ -0,0 +1,44 @@ + 'laravel', + 'symfony.php' => 'symfony', + default => throw new RuntimeException('A supported framework source cell is required.'), +}; +$fixture = new StaticCredentialFixture(dirname(__DIR__).'/fixtures/auth-configuration/static-credentials-v1.json'); +$cases = new StaticCredentialCases($fixture); +$targets = StaticCredentialFrameworkProbe::targets(); +$seen = []; +$count = 0; +$failed = 0; +foreach ($cases->frameworks() as $case) { + if ($targets[$case['target']]['framework'] !== $framework) { + continue; + } + $seen[$case['target']] = true; + $result = StaticCredentialQualification::check($case); + ++$count; + if ($result['verdict'] !== 'PASS') { + if ($failed < 5) { + fwrite(STDERR, json_encode($result, JSON_THROW_ON_ERROR)."\n"); + } + ++$failed; + } +} +$expectedTargets = array_filter($targets, static fn (array $target): bool => $target['framework'] === $framework); +if ($count === 0 || count($seen) !== count($expectedTargets)) { + throw new RuntimeException('The framework source fixture matrix is incomplete.'); +} +echo json_encode(['qualification' => 'static-credential-framework-source', 'source_only' => true, + 'framework' => $framework, 'cases' => $count, 'failed' => $failed, 'targets' => count($seen)], JSON_THROW_ON_ERROR)."\n"; +exit($failed === 0 ? 0 : 1);