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/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/conformance/Support/StaticCredentialCases.php b/scripts/conformance/Support/StaticCredentialCases.php new file mode 100644 index 00000000..1903373e --- /dev/null +++ b/scripts/conformance/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/conformance/Support/StaticCredentialFixture.php b/scripts/conformance/Support/StaticCredentialFixture.php new file mode 100644 index 00000000..e976725c --- /dev/null +++ b/scripts/conformance/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/conformance/Support/StaticCredentialFrameworkProbe.php b/scripts/conformance/Support/StaticCredentialFrameworkProbe.php new file mode 100644 index 00000000..d4aa3887 --- /dev/null +++ b/scripts/conformance/Support/StaticCredentialFrameworkProbe.php @@ -0,0 +1,227 @@ + */ + 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.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.self::BOOTSTRAP_DIRECTORY, 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.self::BOOTSTRAP_DIRECTORY, $base] as $path) { + if (is_file($path)) { + unlink($path); + } elseif (is_dir($path)) { + rmdir($path); + } + } + } +} diff --git a/scripts/conformance/Support/StaticCredentialProbe.php b/scripts/conformance/Support/StaticCredentialProbe.php new file mode 100644 index 00000000..e1f83cc9 --- /dev/null +++ b/scripts/conformance/Support/StaticCredentialProbe.php @@ -0,0 +1,173 @@ + '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(self::ENDPOINT, transport: $transport, + token: $options['shared'] ?? null, controlToken: $options['control'] ?? null, workerToken: $options['worker'] ?? null), + 'ServiceConfiguration', '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; + } + + /** + * @param array $options + * @return array + */ + public static function constructor(array $options): array + { + $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]; + } + + /** + * @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 + { + $credentials = []; + foreach ($options as $role => $value) { + $credentials[self::CONFIGURATION[$role]] = $value; + } + $configuration = ServiceConfiguration::fromArray(['endpoint' => self::ENDPOINT, + '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 + { + $values = self::processValues($options, $values); + $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)); + } + + /** + * @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; + } + } + 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 + { + if (!putenv($setting)) { + throw new StaticCredentialProbeFailure('Fixture environment update failed.'); + } + } +} diff --git a/scripts/conformance/Support/StaticCredentialProbeFailure.php b/scripts/conformance/Support/StaticCredentialProbeFailure.php new file mode 100644 index 00000000..f4db4fcf --- /dev/null +++ b/scripts/conformance/Support/StaticCredentialProbeFailure.php @@ -0,0 +1,12 @@ + $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, + }; + $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' => $failure]; + } + + 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/conformance/Support/StaticCredentialSubject.php b/scripts/conformance/Support/StaticCredentialSubject.php new file mode 100644 index 00000000..dd3077d5 --- /dev/null +++ b/scripts/conformance/Support/StaticCredentialSubject.php @@ -0,0 +1,267 @@ + $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', 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) !== [] + || ($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 ([self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE] 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 ([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 === 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 + || !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'][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'], + '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) { + $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' => self::LARAVEL_PACKAGE, + 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle' => self::SYMFONY_PACKAGE]; + foreach ($frameworks as $class => $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); + } + 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 + * @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) === 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']) { + 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/conformance/Support/StaticCredentialTransportSpy.php b/scripts/conformance/Support/StaticCredentialTransportSpy.php new file mode 100644 index 00000000..cd21c97a --- /dev/null +++ b/scripts/conformance/Support/StaticCredentialTransportSpy.php @@ -0,0 +1,63 @@ +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/conformance/qualify-static-credentials.php b/scripts/conformance/qualify-static-credentials.php new file mode 100644 index 00000000..94b79f2c --- /dev/null +++ b/scripts/conformance/qualify-static-credentials.php @@ -0,0 +1,53 @@ +intent['requirements']['php']) { + throw new StaticCredentialProbeFailure('The proposed PHP interpreter differs.'); + } + $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'; + $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/conformance/static-credentials.md b/scripts/conformance/static-credentials.md new file mode 100644 index 00000000..d1a53164 --- /dev/null +++ b/scripts/conformance/static-credentials.md @@ -0,0 +1,99 @@ +# 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 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. +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/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 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 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 pure preflight, core fixture matrix and refusal controls have source +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. + +The offline loader/regression controls use an existing source vendor: + +```sh +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 +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 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 +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..406b9679 --- /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/StaticCredentialProbeTest.php b/tests/StaticCredentialProbeTest.php new file mode 100644 index 00000000..35489f6b --- /dev/null +++ b/tests/StaticCredentialProbeTest.php @@ -0,0 +1,122 @@ +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', '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); + StaticCredentialProbe::headers('Client', [], 'unsupported', new StaticCredentialTransportSpy()); + } +} diff --git a/tests/StaticCredentialQualificationTest.php b/tests/StaticCredentialQualificationTest.php new file mode 100644 index 00000000..fff68824 --- /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..7b3402fd --- /dev/null +++ b/tests/StaticCredentialSubjectTest.php @@ -0,0 +1,145 @@ + 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(); + } + + 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 + { + $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/StaticCredentialValidationTest.php b/tests/StaticCredentialValidationTest.php index 19fde212..dc2d5b2b 100644 --- a/tests/StaticCredentialValidationTest.php +++ b/tests/StaticCredentialValidationTest.php @@ -7,8 +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; @@ -16,6 +17,9 @@ use PHPUnit\Framework\TestCase; use ReflectionProperty; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialFixture.php'; +require_once dirname(__DIR__).'/scripts/conformance/Support/StaticCredentialProbe.php'; + final class StaticCredentialValidationTest extends TestCase { private const TARGETS = ['Client', 'TokenAuthentication', 'ServiceConfiguration', 'ProcessCredentialResolver']; @@ -225,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); } /** @@ -321,16 +276,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 +296,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/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); 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 @@ +